Entry-point-first reading
Find the doors, walk one real request through, and read only what it touches.
Predict first
Before you read: predict
ExamHall's API is a NestJS repo you have not seen. MR !3340 adds one file, and its unit test, which builds new FlagsController(service) and calls flag('a1', { reason: 'second face' }), passes. src/app.module.ts imports AttemptsModule, and the MR changes no other file:
After the merge, a candidate's laptop sends POST /attempts/a1/flags with its x-candidate header.
What comes back?
// src/attempts/flags.controller.ts (new)import { Body, Controller, Param, Post, UseGuards } from '@nestjs/common';import { AttemptsService } from './attempts.service';import { CandidateGuard } from './candidate.guard'; @Controller('attempts')@UseGuards(CandidateGuard)export class FlagsController { constructor(private readonly attempts: AttemptsService) {} @Post(':id/flags') flag(@Param('id') id: string, @Body() body: { reason: string }) { return this.attempts.flagForReview(id, body.reason); }}// src/attempts/attempts.module.ts (unchanged)import { Module } from '@nestjs/common';import { AttemptsController } from './attempts.controller';import { SyncController } from './sync.controller';import { AttemptsService } from './attempts.service';import { SyncService } from './sync.service';import { AttemptsRepository } from './attempts.repository';import { CandidateGuard } from './candidate.guard'; @Module({ controllers: [AttemptsController, SyncController], providers: [AttemptsService, SyncService, AttemptsRepository, CandidateGuard], exports: [AttemptsRepository],})export class AttemptsModule {}The 30-second version
Start where work enters: the routes Nest maps, the controllers its modules list, queue consumers and scheduled jobs. Pick one real request and follow each call to where it leaves the process. Read a helper only when the path calls it, and sketch hops and boundaries as you go.
Find the doors, walk one real request through, and read only what it touches.
How it works
Step 1 of 12
In the kitchen
- she starts at the entrances on the club's register, where people and water get in → entry points first (step 1)
Analogy: A cave surveyor tracing one stream through a cave system she has never mapped
| Underground | In the codebase |
|---|---|
| she starts at the entrances on the club's register, where people and water get in | entry points first (step 1) |
| a passage drawn on an old survey but sealed by a rockfall leads nowhere | a controller no module lists serves nothing (step 2) |
| she reads the entrance register before going underground | the route map (step 3) |
| she pours dye into one stream sink and follows the coloured water, passage by passage | one real request, one call at a time (steps 4–5) |
| the trace ends where the dye surfaces at the spring; the river beyond is another survey | stop at the edge (step 8) |
| after rain on the hill, a flood pulse arrives with nobody entering the cave | queued and scheduled work starts elsewhere (step 9) |
| before gating a passage, she checks every entrance whose route runs through it | every door to a shared step (step 11) |
| she draws the survey line as she goes | sketch as you go (step 12) |
In real terms: a request is one path through a few files; finding the doors and walking one path gives a map you can check, where reading every file gives a list of names.
Breaks down when: water follows the rock and gravity, and dye shows where it went. In code, the next hop is sometimes chosen elsewhere: by a module's providers, a queue's job name or a schedule, so the trace jumps to the wiring file.
Contrasting case: a 40-line script with one entry and no calls into other files is read top to bottom; tracing pays once control jumps between files.
Examples, run for real
4.1 ExamHall EXH-3040: the doors before the files
EXH-3040: asked how a saved answer flows, a new engineer read ExamHall's API from the first file in the listing. The second attempt started from the route map Nest prints at startup:
4.1 src/: 19 files · startup log: 4 routes mapped, 3 controllers
4.1 route map | AttemptsController {/attempts}:
4.1 route map | Mapped {/attempts/:id/answers, POST} route
4.1 route map | SyncController {/sync}:
4.1 route map | Mapped {/sync/attempts/:id, POST} route
4.1 route map | ConsoleController {/console}:
4.1 route map | Mapped {/console/answers.csv, GET} route
4.1 route map | Mapped {/console/attempts/:id/answers/:questionId, PATCH} route
4.1 trace GET /console/answers.csv -> 200 · ConsoleController.exportAnswers > AnswersRepository.all > answer.findMany > toCsvRow · leaves the process at answer.findMany (database stand-in)
4.1 src/answers/answer-format.ts: 5 functions · entered by this trace: toCsvRow
Four routes, three controllers: those are the doors (step 3). Tracing the export reaches one helper of the five in answer-format.ts; the other four wait until a trace calls them (step 12).
4.2 Proof domain, ticketing: one trace against a linear read
4.2 trace DELETE /holds/h-s1-b1 -> 200 · HoldsController.remove > HoldsService.release > SeatsRepository.deleteHold > SeatsRepository.findHold > hold.findFirst > hold.remove > seat.upsert
4.2 lines read: the trace's 4 methods, 17 lines in 3 files · src/ in listing order until the hold.remove call: 171 lines in 9 files
Releasing a hold enters four methods, and the trace stops where they call the database stand-in (step 8). Reading the seat service's files in listing order covers 171 lines in 9 files before it reaches the same delete (the common trap).
Picture it

Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
On ExamHall's API, a candidate's laptop sends POST /attempts/a1/answers with { "questionId": "q7", "choice": " b " } and its x-candidate header. Two of the files it passes through:
In what order does the request first enter each of these?
// src/attempts/attempts.controller.ts (excerpt) constructor(private readonly attempts: AttemptsService) {} @Post(':id/answers') saveAnswer(@Param('id') id: string, @Body() body: { questionId: string; choice: string }) { return this.attempts.saveAnswer(id, body.questionId, body.choice); }// src/attempts/attempts.service.ts (excerpt) constructor( private readonly repo: AttemptsRepository, private readonly queue: QueueService, private readonly proctor: ProctorClient, ) {} async saveAnswer(attemptId: string, questionId: string, choice: string) { const attempt = await this.repo.findOpen(attemptId); if (!attempt) throw new NotFoundException(); await this.repo.recordAnswer(attemptId, questionId, normaliseChoice(choice)); await this.queue.add('grade-answer', { attemptId, questionId }); return { saved: true }; }Your order
- 1empty
- 2empty
- 3empty
- 4empty
- 5empty
- 6empty
Tap the lines in the order you expect.
Question 2 of 7
A box office's seat service. PrismaService is its database client, QueueService its job queue's client and PaymentsClient the card provider's API client. A buyer's browser sends POST /holds with { "seatId": "s1", "buyerId": "b1" }:
Before it replies, which of those three systems does this request reach?
// src/holds/holds.controller.ts (excerpt) @Post() create(@Body() body: { seatId: string; buyerId: string }) { return this.checkout.holdForBuyer(body.seatId, body.buyerId); }// src/holds/checkout.service.ts (excerpt) constructor(private readonly holds: HoldsService) {} async holdForBuyer(seatId: string, buyerId: string) { if ((await this.holds.countFor(buyerId)) >= HOLDS_PER_BUYER) throw new ConflictException('hold limit reached'); return this.holds.place(seatId, buyerId); }// src/holds/holds.service.ts (excerpt) constructor( private readonly seats: SeatsRepository, private readonly queue: QueueService, private readonly payments: PaymentsClient, ) {} async place(seatId: string, buyerId: string) { const seat = await this.seats.findSeat(seatId); if (!seat) throw new NotFoundException(); if (seat.heldBy) throw new ConflictException('seat is held'); const hold = await this.seats.createHold(seatId, buyerId); await this.queue.add('expire-hold', { holdId: hold.id }, { delayMs: HOLD_MS }); return hold; } countFor(buyerId: string) { return this.seats.countHolds(buyerId); } async confirm(holdId: string, cardToken: string) { const hold = await this.seats.findHold(holdId); if (!hold) throw new NotFoundException(); await this.payments.charge(cardToken, hold.priceCents); await this.seats.markSold(hold.seatId); return { sold: hold.seatId }; }// src/holds/seats.repository.ts (excerpt) constructor(private readonly prisma: PrismaService) {}Question 3 of 7
MR !3341, "refuse holds on sold seats", changes one file of the box office's seat service, and its new test, a sold seat sent to POST /holds gets 409, passes. HoldsModule already provides SeatsRepository, and HoldsController.create returns this.checkout.holdForBuyer(body.seatId, body.buyerId):
Which review comment names a real defect in this MR?
// src/holds/checkout.service.ts (after the MR)import { ConflictException, Injectable } from '@nestjs/common';import { HoldsService } from './holds.service';import { SeatsRepository } from './seats.repository'; export const HOLDS_PER_BUYER = 4; // A buyer's own checkout: at most four seats held at a time.@Injectable()export class CheckoutService { constructor(private readonly holds: HoldsService, private readonly seats: SeatsRepository) {} async holdForBuyer(seatId: string, buyerId: string) { if ((await this.holds.countFor(buyerId)) >= HOLDS_PER_BUYER) throw new ConflictException('hold limit reached'); const seat = await this.seats.findSeat(seatId); if (seat?.status === 'sold') throw new ConflictException('seat is sold'); return this.holds.place(seatId, buyerId); }}// src/groups/group-bookings.controller.ts (unchanged)import { Body, Controller, Param, Post } from '@nestjs/common';import { HoldsService } from '../holds/holds.service'; // School and club bookings hold several seats in one request.@Controller('group-bookings')export class GroupBookingsController { constructor(private readonly holds: HoldsService) {} @Post(':groupId/holds') async holdSeats(@Param('groupId') groupId: string, @Body() body: { seatIds: string[] }) { const held = []; for (const seatId of body.seatIds) held.push(await this.holds.place(seatId, groupId)); return held; }}Question 4 of 7
Ticket for ExamHall's API: laptops that reconnect late upload answers after time-up through offline sync. Refuse every answer a candidate sends after the attempt's endsAt; staff corrections in the proctor console stay allowed after it. A new DeadlineService.assertOpen(attemptId) throws 409 once endsAt has passed. The routes, and what each handler calls:
Where do you add await this.deadlines.assertOpen(attemptId); as the first line?
// src/attempts/attempts.controller.ts (excerpt) constructor(private readonly attempts: AttemptsService) {} @Post(':id/answers') return this.attempts.saveAnswer(id, body.questionId, body.choice);// src/attempts/sync.controller.ts (excerpt)@Controller('sync') constructor(private readonly sync: SyncService) {} @Post('attempts/:id') return this.sync.upload(id, body.answers);// src/attempts/sync.service.ts (excerpt) async upload(attemptId: string, answers: { questionId: string; choice: string }[]) { for (const a of answers) await this.attempts.saveAnswer(attemptId, a.questionId, a.choice);// src/console/console.controller.ts (excerpt)@Controller('console') constructor(private readonly answers: AnswersRepository, private readonly attempts: AttemptsRepository) {} @Patch('attempts/:id/answers/:questionId') return this.attempts.recordAnswer(id, questionId, body.choice);// src/attempts/attempts.service.ts (excerpt) async saveAnswer(attemptId: string, questionId: string, choice: string) { await this.repo.recordAnswer(attemptId, questionId, normaliseChoice(choice));Question 5 of 7
The interviewer hands you the box office's seat service and asks how one request flows: a buyer who holds seat s1 pays for it.
In at most six lines, write each hop's class and method in order, and where the request leaves the process.
Question 6 of 7
A team chat's NestJS service. Each morning, each member with unread messages gets one email listing them. Its startup log maps one route, and a search of src/ for route decorators and schedule registrations prints:
MessagesController {/messages}:
Mapped {/messages/unread, GET} route
src/messages/messages.controller.ts:8: @Get('unread')
src/summary/morning-summary.ts:18: this.scheduler.daily('07:00', () => this.send());
Which method does that email's flow enter first?
Question 7 of 7
An MR to the team chat adds reactions. It adds two files and changes MessagesModule; the root module and InfraModule are unchanged:
After the merge, a member's app sends POST /reactions with { "messageId": "x1", "emoji": "👍" }.
What comes back?
// src/reactions/reactions.module.ts (new)import { Module } from '@nestjs/common';import { ReactionsController } from './reactions.controller'; @Module({ controllers: [ReactionsController] })export class ReactionsModule {}// src/reactions/reactions.controller.ts (new)import { Body, Controller, Post } from '@nestjs/common';import { PrismaService } from '../infra/prisma.service'; @Controller('reactions')export class ReactionsController { constructor(private readonly prisma: PrismaService) {} @Post() react(@Body() body: { messageId: string; emoji: string }) { return this.prisma.reaction.create({ key: `${body.messageId}:${body.emoji}`, data: body }); }}// src/messages/messages.module.ts (after the MR)import { Module } from '@nestjs/common';import { MessagesController } from './messages.controller';import { MessagesRepository } from './messages.repository';import { ReactionsModule } from '../reactions/reactions.module'; @Module({ imports: [ReactionsModule], controllers: [MessagesController], providers: [MessagesRepository], exports: [MessagesRepository] })export class MessagesModule {}// src/infra/infra.module.ts (unchanged)import { Global, Module } from '@nestjs/common';import { PrismaService } from './prisma.service';import { SchedulerService } from './scheduler.service';import { MailService } from './mail.service'; @Global()@Module({ providers: [PrismaService, SchedulerService, MailService], exports: [PrismaService, SchedulerService, MailService] })export class InfraModule {}// src/app.module.ts (unchanged)import { Module } from '@nestjs/common';import { InfraModule } from './infra/infra.module';import { MessagesModule } from './messages/messages.module';import { SummaryModule } from './summary/summary.module'; @Module({ imports: [InfraModule, MessagesModule, SummaryModule] })export class AppModule {}Go deeper
Pro tip
- Let Nest print the doors. Start the app once locally with the logger on:
RoutesResolvernames each controller andRouterExplorereach route it mapped, which is the list a trace starts from. When: the first minutes in an unfamiliar NestJS repo, before opening any service file (step 3). Verify (RAN,tips.mjs):
Status: RAN.tip startup log, ExamHall fixture: RoutesResolver 3 controllers · RouterExplorer 4 routes · first: Mapped {/attempts/:id/answers, POST} route
Source
Every behaviour this lesson grades is executed on NestJS 12.1.2; the docs support the steps and key nothing. Valid as of 2026-10-06.
- NestJS documentation, Modules (the curriculum row's first source), https://docs.nestjs.com/modules (
nestjs-modules.txt). Quoted: "It is the starting point from which Nest builds the application graph". Supports step 2. - NestJS documentation, Request lifecycle (the row's second source), https://docs.nestjs.com/faq/request-lifecycle (
nestjs-request-lifecycle.txt). Quoted: "it can be challenging to track down where a particular piece of code runs during the request lifecycle". Background for steps 4–5; the stage order belongs tonest-request-lifecycleand keys nothing here. - NestJS documentation, Controllers, https://docs.nestjs.com/controllers (
nestjs-controllers.txt). Quoted: "Controllers must always belong to a module, which is why we include thecontrollersarray in the@Module()decorator." Supports step 2.
Related lessons
- Related: Orienting in an unfamiliar codebase fast
- Related: Dependency inversion and NestJS dependency injection
- Related: Single responsibility as one reason to change
- Related: Explain line N
- Related: Defect taxonomy scan
- Related: Async and race defects
- Related: Security lens in review
- Related: Performance lens in review
- Related: Extending an existing codebase live
- Related: The request pipeline: middleware, guards, interceptors, pipes, handler, filters
Checked against the tools and docs as of 2026-10-06.