Clean architecture layers in a NestJS feature
Every way in calls the use case; put the rules and the transaction where every way in passes.
Predict first
Before you read: predict
ExamHall's grading feature has three ways in: the web app, the mobile app's v2 API, and the offline-centre import, a script that test centres run when their upload comes in. Exam e-1 has closed. The files are below.
Three late attempts of exam e-1 arrive, one each way: POST /attempts/e1-web/submit, POST /m/attempts/e1-mob/submit, and importAttempts(['e1-imp']).
Which of them end up in scored?
// attempts.ts (the store for this run; exam e-1 has closed)export const scored: string[] = [];export const examClosed = (attemptId: string): boolean => attemptId.startsWith('e1-'); // exam-open.guard.tsimport { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common';import { examClosed } from './attempts';@Injectable()export class ExamOpenGuard implements CanActivate { canActivate(ctx: ExecutionContext): boolean { if (examClosed(ctx.switchToHttp().getRequest().params.id)) throw new ForbiddenException('exam closed'); return true; }} // submit-attempt.use-case.tsimport { Injectable } from '@nestjs/common';import { scored } from './attempts';@Injectable()export class SubmitAttempt { execute(attemptId: string): void { scored.push(attemptId); }} // grading.controller.ts (the web app)import { Controller, Param, Post, UseGuards } from '@nestjs/common';import { ExamOpenGuard } from './exam-open.guard';import { SubmitAttempt } from './submit-attempt.use-case';@Controller('attempts')export class GradingController { constructor(private submit: SubmitAttempt) {} @Post(':id/submit') @UseGuards(ExamOpenGuard) post(@Param('id') id: string): void { this.submit.execute(id); }} // mobile.controller.ts (the mobile app's v2 API)import { Controller, Param, Post } from '@nestjs/common';import { SubmitAttempt } from './submit-attempt.use-case';@Controller('m/attempts')export class MobileController { constructor(private submit: SubmitAttempt) {} @Post(':id/submit') post(@Param('id') id: string): void { this.submit.execute(id); }} // grading.module.tsimport { Module } from '@nestjs/common';import { ExamOpenGuard } from './exam-open.guard';import { GradingController } from './grading.controller';import { MobileController } from './mobile.controller';import { SubmitAttempt } from './submit-attempt.use-case';@Module({ controllers: [GradingController, MobileController], providers: [SubmitAttempt, ExamOpenGuard] })export class GradingModule {} // import-attempts.ts (the offline-centre import)import { NestFactory } from '@nestjs/core';import { GradingModule } from './grading.module';import { SubmitAttempt } from './submit-attempt.use-case';export async function importAttempts(ids: string[]): Promise<void> { const app = await NestFactory.createApplicationContext(GradingModule, { logger: false }); try { for (const id of ids) app.get(SubmitAttempt).execute(id); } finally { await app.close(); }}The 30-second version
In a NestJS feature, controllers turn requests into calls, use cases run one operation each, a plain domain decides, and adapters reach the database. A web route is only one way in: workers, cron jobs and scripts call use cases too, and Nest's guards, pipes and interceptors never see them. So rules and transactions belong to the use case.
Every way in calls the use case; put the rules and the transaction where every way in passes.
How it works
Step 1 of 12
grading/ holds its controller, use cases, domain and adapters; GradingModule wires them. Nest's docs: a feature module "keeps boundaries clear".Analogy: A city's lost property office
| At the office | In code |
|---|---|
| found items arrive at the counter, in the bus depots' nightly sacks, and from the police | many drivers, one operation (step 4) |
| the counter clerk photographs every phone and wallet; items from the sacks and the police go on the shelves with no photo | a check at one door covers that door only (step 5) |
| the photo moves to the intake table that every item crosses before it is shelved | the rule in the use case (step 7) |
| the intake clerk writes the item in the register and gives it its shelf in one entry, never one without the other | the use case owns the transaction (step 8) |
| the opening-hours notice is just edited and pinned up, with no intake | CRUD: a controller and a service (step 12) |
In real terms: before trusting where a rule lives, list every caller of the use case, not only the routes.
Breaks down when: the counter clerk carries an unphotographed phone back to intake; code that skips the use case finds out nothing.
Contrasting case: a café's lost-and-found box, where one barista takes in everything over the counter: with one way in, a check at the door is enough.
Examples, run for real
4.1 ExamHall: the closing time in the controller
EXH-4190: ExamHall's grading controller checked the exam's closing time; test centres' offline uploads came in through an import script that called SubmitAttempt itself. The exam closed at 10:30 and the clock read 10:31:
// grading.controller.ts (as shipped)
@Post(':id/submit')
post(@Param('id') id: string, @Body() dto: SubmitAttemptDto): number {
if (attempts.get(id)!.closesAt < clock.now()) throw new ForbiddenException('exam closed');
return this.submitAttempt.execute(id, dto.answers);
}
MR !4490 moves the check into scoring/, plain functions that SubmitAttempt calls, and leaves the controller one line:
// scoring/submit.ts (MR !4490: no Nest, no Prisma)
export class SubmissionClosed extends Error {}
export function submit(a: Attempt, answers: string[], now: number): Attempt {
if (a.closesAt < now) throw new SubmissionClosed(a.id);
return { ...a, answers, score: answers.filter((x, i) => x === a.key[i]).length };
}
// submit-attempt.use-case.ts (MR !4490)
execute(id: string, answers: string[]): number {
const done = submit(attempts.get(id)!, answers, clock.now());
attempts.set(id, done);
return done.score!;
}
// grading.controller.ts (MR !4490)
@Post(':id/submit')
post(@Param('id') id: string, @Body() dto: SubmitAttemptDto): number {
return this.submitAttempt.execute(id, dto.answers);
}
4.1 EXH-4190 as shipped: tsc 6.0.3 no diagnostics · web, late: 403 · import, late: ok · scored: a-2 2 of 3
4.1 MR !4490: tsc no diagnostics · web, late: 409 · import, late: SubmissionClosed · scored: none · scoring/ imports from @nestjs/* or @prisma/*: 0
Before the MR, the web request met the check and the import did not (steps 4–5). After it, both meet it in submit(), and the module's exception filter turns SubmissionClosed into a 409 for the web (nest-request-lifecycle step 11); scoring/ names no Nest or Prisma module (steps 7, 10).
4.2 What the MR does not decide
exam-rooms/, the admin screens that list and rename rooms, stays a controller and a service calling Prisma: it has no rule to protect (step 12). Writing the attempt and its outbox row in one unit is the use case's next change (step 8; pat-unit-of-work-transactions).
Try it
Answer first, then check. Nothing is saved.
Question 1 of 7
A chat app wraps every HTTP request in a database transaction with a global interceptor. Scheduled messages are sent by a worker. db.ts stands in for the database client: db.insert writes at once; db.transaction(fn) runs fn(tx), and the writes made through tx are kept only if fn finishes without throwing. In this run, every insert into mentions fails. The files are below.
Two messages mention @ana in room r-1: standup now is posted with POST /rooms/r-1/messages, and retro at 4 is sent by sendScheduled. Both fail.
Afterwards, which of them are in the messages table?
// tx-context.tsimport { AsyncLocalStorage } from 'node:async_hooks';import { db, type Client } from './db';export const txStore = new AsyncLocalStorage<Client>();export const client = (): Client => txStore.getStore() ?? db; // transaction.interceptor.tsimport { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';import { from, lastValueFrom } from 'rxjs';import { db } from './db';import { txStore } from './tx-context';@Injectable()export class TransactionInterceptor implements NestInterceptor { intercept(_ctx: ExecutionContext, next: CallHandler) { return from(db.transaction((tx) => txStore.run(tx, () => lastValueFrom(next.handle())))); }} // send-message.use-case.tsimport { Injectable } from '@nestjs/common';import { client } from './tx-context';@Injectable()export class SendMessage { async execute(room: string, text: string, mentions: string[]): Promise<void> { await client().insert('messages', { room, text }); for (const user of mentions) await client().insert('mentions', { room, user }); }} // messages.controller.tsimport { Body, Controller, Param, Post } from '@nestjs/common';import { SendMessage } from './send-message.use-case';@Controller('rooms/:room/messages')export class MessagesController { constructor(private send: SendMessage) {} @Post() post(@Param('room') room: string, @Body() b: { text: string; mentions: string[] }): Promise<void> { return this.send.execute(room, b.text, b.mentions); }} // app.module.tsimport { Module } from '@nestjs/common';import { APP_INTERCEPTOR } from '@nestjs/core';import { MessagesController } from './messages.controller';import { SendMessage } from './send-message.use-case';import { TransactionInterceptor } from './transaction.interceptor';@Module({ controllers: [MessagesController], providers: [SendMessage, { provide: APP_INTERCEPTOR, useClass: TransactionInterceptor }] })export class AppModule {} // scheduled-sender.ts (the worker)import { NestFactory } from '@nestjs/core';import { AppModule } from './app.module';import { SendMessage } from './send-message.use-case';export async function sendScheduled(room: string, text: string, mentions: string[]): Promise<void> { const app = await NestFactory.createApplicationContext(AppModule, { logger: false }); try { await app.get(SendMessage).execute(room, text, mentions); } finally { await app.close(); }}Question 2 of 7
A chat app takes messages three ways: the web app, replies to notification emails, and a bridge from a linked Slack channel. Ana left room r-1 yesterday, and the team wants every post of hers to r-1 kept out. The files are below.
Ana then posts hi to r-1 three ways: POST /rooms/r-1/messages with { "user": "ana", "text": "hi" }, replyByEmail('r-1', 'ana', 'hi') and fromSlack('r-1', 'ana', 'hi', 't-7').
Which change keeps all three out of posted?
// members.ts (the store for this run)export const posted: string[] = [];export const isMember = (room: string, user: string): boolean => !(room === 'r-1' && user === 'ana');export class NotAMember extends Error {} // post.ts (the domain: what a post reads as)import { isMember, NotAMember } from './members';export function composePost(room: string, user: string, text: string): string { // spot 2 return `${room} ${user}: ${text.trim()}`;} // send-message.use-case.tsimport { Injectable } from '@nestjs/common';import { isMember, NotAMember, posted } from './members';import { composePost } from './post';@Injectable()export class SendMessage { execute(room: string, user: string, text: string): void { // spot 1 posted.push(composePost(room, user, text)); // spot 3 }} // relay-from-slack.use-case.tsimport { Injectable } from '@nestjs/common';import { posted } from './members';import { composePost } from './post';@Injectable()export class RelayFromSlack { execute(room: string, user: string, text: string, thread: string): void { posted.push(`${composePost(room, user, text)} [${thread}]`); }} // messages.controller.ts (the web app)import { Body, Controller, Param, Post } from '@nestjs/common';import { SendMessage } from './send-message.use-case';@Controller('rooms/:room/messages')export class MessagesController { constructor(private send: SendMessage) {} @Post() post(@Param('room') room: string, @Body('user') user: string, @Body('text') text: string): void { this.send.execute(room, user, text); }} // chat.module.tsimport { Module } from '@nestjs/common';import { MessagesController } from './messages.controller';import { RelayFromSlack } from './relay-from-slack.use-case';import { SendMessage } from './send-message.use-case';@Module({ controllers: [MessagesController], providers: [SendMessage, RelayFromSlack] })export class ChatModule {} // email-reply.ts (a reply to a notification email)import { NestFactory } from '@nestjs/core';import { ChatModule } from './chat.module';import { SendMessage } from './send-message.use-case';export async function replyByEmail(room: string, user: string, text: string): Promise<void> { const app = await NestFactory.createApplicationContext(ChatModule, { logger: false }); try { app.get(SendMessage).execute(room, user, text); } finally { await app.close(); }} // slack-bridge.ts (a post in the linked Slack channel)import { NestFactory } from '@nestjs/core';import { ChatModule } from './chat.module';import { RelayFromSlack } from './relay-from-slack.use-case';export async function fromSlack(room: string, user: string, text: string, thread: string): Promise<void> { const app = await NestFactory.createApplicationContext(ChatModule, { logger: false }); try { app.get(RelayFromSlack).execute(room, user, text, thread); } finally { await app.close(); }}Question 3 of 7
A chat app's web controller calls MessagesService, and its scheduled-message worker calls the SendMessage use case. db.ts stands in for the database client: db.insert writes at once; db.transaction(fn) runs fn(tx), and the writes made through tx are kept only if fn finishes without throwing. ports.ts, chat.module.ts and messages.controller.ts are the same in every version; the other three files come in three versions. The files are below.
The team wants two things: when an insert into mentions fails, no message is left behind, whether the web app or the worker sent it; and send-message.use-case.ts imports nothing from ./db, so its tests need no database.
Which version gives both?
// ports.tsimport type { Row } from './db';export interface Writer { insert(table: string, row: Row): Promise<void> }export interface UnitOfWork { run<T>(work: (w: Writer) => Promise<T>): Promise<T> }export const UNIT_OF_WORK = Symbol('UNIT_OF_WORK'); // chat.module.tsimport { Module } from '@nestjs/common';import { db } from './db';import { MessagesController } from './messages.controller';import { MessagesService } from './messages.service';import { UNIT_OF_WORK, type UnitOfWork } from './ports';import { SendMessage } from './send-message.use-case';const unitOfWork: UnitOfWork = { run: (work) => db.transaction(work) };@Module({ controllers: [MessagesController], providers: [MessagesService, SendMessage, { provide: UNIT_OF_WORK, useValue: unitOfWork }] })export class ChatModule {} // messages.controller.tsimport { Body, Controller, Param, Post } from '@nestjs/common';import { MessagesService } from './messages.service';@Controller('rooms/:room/messages')export class MessagesController { constructor(private messages: MessagesService) {} @Post() post(@Param('room') room: string, @Body() b: { text: string; mentions: string[] }): Promise<void> { return this.messages.send(room, b.text, b.mentions); }} // send-message.use-case.ts (version 1)import { Injectable } from '@nestjs/common';import type { Writer } from './ports';@Injectable()export class SendMessage { async execute(w: Writer, room: string, text: string, mentions: string[]): Promise<void> { await w.insert('messages', { room, text }); for (const user of mentions) await w.insert('mentions', { room, user }); }} // messages.service.ts (version 1)import { Injectable } from '@nestjs/common';import { db } from './db';import { SendMessage } from './send-message.use-case';@Injectable()export class MessagesService { constructor(private sendMessage: SendMessage) {} send(room: string, text: string, mentions: string[]): Promise<void> { return db.transaction((tx) => this.sendMessage.execute(tx, room, text, mentions)); }} // scheduled-sender.ts (version 1)import { NestFactory } from '@nestjs/core';import { ChatModule } from './chat.module';import { db } from './db';import { SendMessage } from './send-message.use-case';export async function sendScheduled(room: string, text: string, mentions: string[]): Promise<void> { const app = await NestFactory.createApplicationContext(ChatModule, { logger: false }); try { await app.get(SendMessage).execute(db, room, text, mentions); } finally { await app.close(); }} // send-message.use-case.ts (version 2)import { Injectable } from '@nestjs/common';import { db } from './db';@Injectable()export class SendMessage { execute(room: string, text: string, mentions: string[]): Promise<void> { return db.transaction(async (tx) => { await tx.insert('messages', { room, text }); for (const user of mentions) await tx.insert('mentions', { room, user }); }); }} // send-message.use-case.ts (version 3)import { Inject, Injectable } from '@nestjs/common';import { UNIT_OF_WORK, type UnitOfWork } from './ports';@Injectable()export class SendMessage { constructor(@Inject(UNIT_OF_WORK) private uow: UnitOfWork) {} execute(room: string, text: string, mentions: string[]): Promise<void> { return this.uow.run(async (w) => { await w.insert('messages', { room, text }); for (const user of mentions) await w.insert('mentions', { room, user }); }); }} // messages.service.ts (versions 2 and 3)import { Injectable } from '@nestjs/common';import { SendMessage } from './send-message.use-case';@Injectable()export class MessagesService { constructor(private sendMessage: SendMessage) {} send(room: string, text: string, mentions: string[]): Promise<void> { return this.sendMessage.execute(room, text, mentions); }} // scheduled-sender.ts (versions 2 and 3)import { NestFactory } from '@nestjs/core';import { ChatModule } from './chat.module';import { SendMessage } from './send-message.use-case';export async function sendScheduled(room: string, text: string, mentions: string[]): Promise<void> { const app = await NestFactory.createApplicationContext(ChatModule, { logger: false }); try { await app.get(SendMessage).execute(room, text, mentions); } finally { await app.close(); }}Question 4 of 7
Lay out two ExamHall features as stub files with their real import lines. grading/ submits an attempt: it refuses one after the exam closes, scores it and saves it in one Prisma transaction, and the web app and the offline-centre import both call it. exam-rooms/ lists and renames rooms, with no rule. Put grading's files under grading/domain/, grading/application/, grading/infrastructure/ and grading/http/, with grading/grading.module.ts, and exam-rooms' under exam-rooms/.
For each file, write its layer and what it imports.
Question 5 of 7
A chat app's scheduled-message worker sent messages every morning until an MR merged yesterday. Since then sendScheduled('r-1', 'ben', 'standup now') throws before anything is posted, though Ben is a member of r-1. The MR made three changes, marked in the files below.
Undoing which one change, with the other two kept, lets sendScheduled post again?
// members.ts (the store for this run)export const posted: string[] = [];export const isMember = (room: string, user: string): boolean => user === 'ben'; // can-post.ts (change 1: new in the MR)import { ForbiddenException } from '@nestjs/common';export function assertCanPost(member: boolean): void { if (!member) throw new ForbiddenException('not a member'); } // send-message.use-case.ts (change 2: the constructor and `via`)import { Inject, Injectable } from '@nestjs/common';import { REQUEST } from '@nestjs/core';import { assertCanPost } from './can-post';import { isMember, posted } from './members';@Injectable()export class SendMessage { constructor(@Inject(REQUEST) private req: { headers: Record<string, string | undefined> }) {} execute(room: string, user: string, text: string): void { assertCanPost(isMember(room, user)); const via = this.req.headers['x-client'] ?? 'unknown'; posted.push(`${user}: ${text} (via ${via})`); }} // audit.interceptor.ts (new in the MR)import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';import { tap } from 'rxjs';export const audit: string[] = [];@Injectable()export class AuditInterceptor implements NestInterceptor { intercept(ctx: ExecutionContext, next: CallHandler) { return next.handle().pipe(tap(() => audit.push(ctx.switchToHttp().getRequest().url))); }} // messages.controller.ts (change 3: `@UseInterceptors`)import { Body, Controller, Param, Post, UseInterceptors } from '@nestjs/common';import { AuditInterceptor } from './audit.interceptor';import { SendMessage } from './send-message.use-case';@Controller('rooms/:room/messages')@UseInterceptors(AuditInterceptor)export class MessagesController { constructor(private send: SendMessage) {} @Post() post(@Param('room') room: string, @Body('user') user: string, @Body('text') text: string): void { this.send.execute(room, user, text); }} // chat.module.tsimport { Module } from '@nestjs/common';import { MessagesController } from './messages.controller';import { SendMessage } from './send-message.use-case';@Module({ controllers: [MessagesController], providers: [SendMessage] })export class ChatModule {} // scheduled-sender.ts (the worker; not changed by the MR)import { NestFactory } from '@nestjs/core';import { ChatModule } from './chat.module';import { SendMessage } from './send-message.use-case';export async function sendScheduled(room: string, user: string, text: string): Promise<void> { const app = await NestFactory.createApplicationContext(ChatModule, { logger: false }); try { app.get(SendMessage).execute(room, user, text); } finally { await app.close(); }}Question 6 of 7
A streaming service keeps 4K for paid plans. Its web player and its TV app each have their own controller, in their own module, and a worker moves a running stream to the viewer's next device. The files are below.
Free viewer v-9 asks for 4K three ways: POST /streams and POST /tv/streams, each with { "viewer": "v-9", "quality": "4k" }, and resumeOnNextDevice('v-9', '4k').
Which of them start a 4K stream?
// viewers.ts (the store for this run; viewer v-9 is on the free plan)export const started: string[] = [];export const isFree = (viewer: string): boolean => viewer === 'v-9'; // stream-log.tsimport { Injectable } from '@nestjs/common';import { started } from './viewers';@Injectable()export class StreamLog { open(line: string): void { started.push(line); }} // plan.guard.tsimport { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';import { isFree } from './viewers';@Injectable()export class PlanGuard implements CanActivate { canActivate(ctx: ExecutionContext): boolean { const b = ctx.switchToHttp().getRequest().body; return !(b.quality === '4k' && isFree(b.viewer)); }} // start-stream.use-case.tsimport { Injectable } from '@nestjs/common';import { StreamLog } from './stream-log';@Injectable()export class StartStream { constructor(private log: StreamLog) {} execute(viewer: string, quality: string, from: string): void { this.log.open(`${from} ${quality}`); }} // streams.module.ts (the web player)import { Body, Controller, Module, Post } from '@nestjs/common';import { APP_GUARD } from '@nestjs/core';import { PlanGuard } from './plan.guard';import { StartStream } from './start-stream.use-case';import { StreamLog } from './stream-log';@Controller('streams')export class WebController { constructor(private start: StartStream) {} @Post() post(@Body() b: { viewer: string; quality: string }): void { this.start.execute(b.viewer, b.quality, 'web'); }}@Module({ controllers: [WebController], providers: [StartStream, StreamLog, { provide: APP_GUARD, useClass: PlanGuard }], exports: [StartStream] })export class StreamsModule {} // tv.module.ts (the TV app)import { Body, Controller, Module, Post } from '@nestjs/common';import { StartStream } from './start-stream.use-case';import { StreamsModule } from './streams.module';@Controller('tv/streams')export class TvController { constructor(private start: StartStream) {} @Post() post(@Body() b: { viewer: string; quality: string }): void { this.start.execute(b.viewer, b.quality, 'tv'); }}@Module({ imports: [StreamsModule], controllers: [TvController] })export class TvModule {} // app.module.tsimport { Module } from '@nestjs/common';import { StreamsModule } from './streams.module';import { TvModule } from './tv.module';@Module({ imports: [StreamsModule, TvModule] })export class AppModule {} // resume-worker.ts (moves a stream to the next device)import { NestFactory } from '@nestjs/core';import { AppModule } from './app.module';import { StartStream } from './start-stream.use-case';export async function resumeOnNextDevice(viewer: string, quality: string): Promise<void> { const app = await NestFactory.createApplicationContext(AppModule, { logger: false }); try { app.get(StartStream).execute(viewer, quality, 'resume'); } finally { await app.close(); }}Question 7 of 7
A box office's venue admin is a controller and a service; the CSV import that venues send at the start of each season calls the same service. Venue v-1 has 400 seats and has sold 350. A new rule: a venue's capacity may not drop below the seats it has sold. Today's code is the controller marked "version 2, today" and the service marked "versions 1 and 3, today", with the module and the import marked "versions 1 and 2". The files are below.
Which version refuses to cut v-1 to 300 seats both from the admin page (PATCH /venues/v-1 with { "capacity": 300 }) and from importVenues([['v-1', 300]]), with the fewest files added or changed against today's code?
// venues.ts (the store for this run)export const venues = new Map([['v-1', { capacity: 400, sold: 350 }]]);export class BelowSold extends Error {} // venues.service.ts (versions 1 and 3, today)import { Injectable } from '@nestjs/common';import { venues } from './venues';@Injectable()export class VenuesService { update(id: string, capacity: number): void { venues.get(id)!.capacity = capacity; }} // venues.service.ts (version 2)import { Injectable } from '@nestjs/common';import { BelowSold, venues } from './venues';@Injectable()export class VenuesService { update(id: string, capacity: number): void { if (capacity < venues.get(id)!.sold) throw new BelowSold(id); venues.get(id)!.capacity = capacity; }} // venues.controller.ts (version 1)import { Body, Controller, Param, Patch } from '@nestjs/common';import { BelowSold, venues } from './venues';import { VenuesService } from './venues.service';@Controller('venues')export class VenuesController { constructor(private service: VenuesService) {} @Patch(':id') patch(@Param('id') id: string, @Body('capacity') capacity: number): void { if (capacity < venues.get(id)!.sold) throw new BelowSold(id); this.service.update(id, capacity); }} // venues.controller.ts (version 2, today)import { Body, Controller, Param, Patch } from '@nestjs/common';import { VenuesService } from './venues.service';@Controller('venues')export class VenuesController { constructor(private service: VenuesService) {} @Patch(':id') patch(@Param('id') id: string, @Body('capacity') capacity: number): void { this.service.update(id, capacity); }} // venues.module.ts (versions 1 and 2)import { Module } from '@nestjs/common';import { VenuesController } from './venues.controller';import { VenuesService } from './venues.service';@Module({ controllers: [VenuesController], providers: [VenuesService] })export class VenuesModule {} // import-venues.ts (versions 1 and 2)import { NestFactory } from '@nestjs/core';import { VenuesModule } from './venues.module';import { VenuesService } from './venues.service';export async function importVenues(rows: [string, number][]): Promise<void> { const app = await NestFactory.createApplicationContext(VenuesModule, { logger: false }); try { for (const [id, capacity] of rows) app.get(VenuesService).update(id, capacity); } finally { await app.close(); }} // capacity.ts (version 3)import { BelowSold } from './venues';export function checkCapacity(id: string, capacity: number, sold: number): void { if (capacity < sold) throw new BelowSold(id); } // update-capacity.use-case.ts (version 3)import { Injectable } from '@nestjs/common';import { checkCapacity } from './capacity';import { venues } from './venues';@Injectable()export class UpdateCapacity { execute(id: string, capacity: number): void { checkCapacity(id, capacity, venues.get(id)!.sold); venues.get(id)!.capacity = capacity; }} // venues.controller.ts (version 3)import { Body, Controller, Param, Patch } from '@nestjs/common';import { UpdateCapacity } from './update-capacity.use-case';@Controller('venues')export class VenuesController { constructor(private update: UpdateCapacity) {} @Patch(':id') patch(@Param('id') id: string, @Body('capacity') capacity: number): void { this.update.execute(id, capacity); }} // venues.module.ts (version 3)import { Module } from '@nestjs/common';import { UpdateCapacity } from './update-capacity.use-case';import { VenuesController } from './venues.controller';import { VenuesService } from './venues.service';@Module({ controllers: [VenuesController], providers: [VenuesService, UpdateCapacity] })export class VenuesModule {} // import-venues.ts (version 3)import { NestFactory } from '@nestjs/core';import { UpdateCapacity } from './update-capacity.use-case';import { VenuesModule } from './venues.module';export async function importVenues(rows: [string, number][]): Promise<void> { const app = await NestFactory.createApplicationContext(VenuesModule, { logger: false }); try { for (const [id, capacity] of rows) app.get(UpdateCapacity).execute(id, capacity); } finally { await app.close(); }}Go deeper
Pro tips
- List the callers before you place a rule. Search for every caller of the use case (constructor parameters,
app.get(…)), not only the routes, and check which of them pass the guard or pipe that holds the rule. When: reviewing an MR that adds a check to a guard, pipe, interceptor or controller (steps 4–5). Verify (RAN,tips.mjs): in t1's files, 3 files callSubmitAttemptand 1 of them is behindExamOpenGuard. - Make the layout a test. The row's own tip: the domain folder may not import
@nestjs/*or@prisma/client; one check in CI keeps that true after its author leaves (arch-layered-hexagonalowns the import-graph check). When: setting up a feature module (steps 10–11).
Source
- NestJS docs, Modules (the row's source; Markdown source of docs.nestjs.com at commit
64337657): https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/modules.md · fetched 2026-10-14. "A feature module organizes the code for a specific feature, which keeps boundaries clear." (step 1). Teaching only. Valid as of 2026-10-14. - NestJS docs, Custom providers (the row's source; the page's Markdown is
fundamentals/dependency-injection.md): https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/fundamentals/dependency-injection.md · fetched 2026-10-14. "In this example, we are associating a string-valued token ('CONNECTION') with a pre-existingconnectionobject we've imported from an external file.". Teaching only. Valid as of 2026-10-14. - NestJS docs, Providers (not named by the row; teaching only): https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/components.md · fetched 2026-10-14. "Controllers should handle HTTP requests and delegate more complex tasks to providers." (step 2; the bold is the page's Markdown). Valid as of 2026-10-14.
- NestJS docs, Standalone applications (not named by the row; teaching only): https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/application-context.md · fetched 2026-10-14. "Keep in mind that a standalone application does not have any network listeners, so any Nest features related to HTTP (e.g., middleware, interceptors, pipes, guards, etc.) are not available in this context." (step 5). Every item that uses a standalone context runs it on NestJS 12.1.2; the page decides no key. Valid as of 2026-10-14.
- NestJS docs, Guards (not named by the row; teaching only): https://raw.githubusercontent.com/nestjs/docs.nestjs.com/64337657a90bccbfac26f346a84203d2d304178b/content/guards.md · fetched 2026-10-14. "When you use this approach to perform dependency injection for the guard, the guard is global regardless of the module in which you register it.". Valid as of 2026-10-14.
- The row's first source, Robert C. Martin, Clean Architecture (2017), is a book and keys nothing; every key here is executed.
Related lessons
- Read first: Layers, ports and adapters
- Read first: One unit of work per business operation
- Read first: DTOs and mappers at the boundaries
- Related: The repository pattern over an ORM
- Related: The request pipeline: middleware, guards, interceptors, pipes, handler, filters
- Related: Dependency inversion and NestJS dependency injection
- Related: Validation at the boundary: ValidationPipe, whitelisting, mass assignment
- Related: Provider scopes and request context: singleton fields and AsyncLocalStorage
- Related: When a pattern is over-engineering
Checked against the tools and docs as of 2026-10-14.