Capstone: a fully-typed, authed, tested API
Tie the whole track together: feature modules over Controller -> Service -> Repository, a core module that binds the global guard/pipe/interceptor/filter, types enforced at the edges, and a production-readiness checklist that fails fast.
The order service ships, and for three weeks it is fine. Then a Friday deploy adds a STRIPE_KEY the team forgot to set in staging — the app boots green, passes liveness, and 404s nothing, because the key is only read the first time someone checks out, at 4pm, in production. The on-call engineer finds three more landmines while digging: a controller that does its own role check and its own validation, a service that opens a raw query outside any transaction, and synchronize: true quietly altering the schema on every restart. None of these are hard bugs. Each is a concern that drifted out of the slot the framework already gave it. This lesson is the assembly drawing: every earlier piece of the track, wired into one service you would actually trust on call.
The skeleton: feature modules over a typed core
When you inherit a codebase where the same role-check appears in a dozen controllers, you feel the cost of every missing seam. The skeleton below makes those seams explicit so each concern has exactly one home.
A service you can reason about has two kinds of module. Feature modules (OrdersModule, UsersModule) own one slice of the domain and follow the same internal shape: a Controller (HTTP edge, no logic), a Service (business operations, the only place rules live), and a Repository (persistence, a typed Repository<T>). A single core module owns everything cross-cutting: a typed ConfigModule, the global ValidationPipe, the global logging interceptor, the global exception filter, the auth guard, and the health controller. Feature modules import the core; nothing imports a feature module’s internals.
The core module is where the lifecycle gets bound once, with DI-aware provider tokens so the guard/filter/interceptor can themselves inject dependencies:
import { Module } from '@nestjs/common';
import { APP_GUARD, APP_PIPE, APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { ConfigModule } from '@nestjs/config';
import { ValidationPipe } from '@nestjs/common';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { AllExceptionsFilter } from './filters/all-exceptions.filter';
import { TransformInterceptor } from './interceptors/transform.interceptor';
import { validateEnv } from './config/env.validation';
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true, validate: validateEnv }), // fail-fast at boot
],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard }, // authn on every route
{
provide: APP_PIPE,
useValue: new ValidationPipe({ whitelist: true, transform: true }),
},
{ provide: APP_INTERCEPTOR, useClass: TransformInterceptor }, // envelope + timing
{ provide: APP_FILTER, useClass: AllExceptionsFilter }, // one error shape
],
})
export class CoreModule {}whitelist: true strips properties with no DTO decorator (defends against mass-assignment); transform: true turns the plain JSON body into a real DTO instance and coerces primitives. Binding via APP_* tokens — not app.useGlobalGuards() — keeps these globals inside the DI container, so the guard can inject Reflector and the filter can inject your Logger.
One request, the fixed lifecycle as the spine
Every request walks the same gauntlet you learned earlier — now read it as the spine of the whole design. JwtAuthGuard (authentication) runs first and reads @Public() metadata so /health and /login opt out. RolesGuard (authorization) reads @Roles() and decides access — settled before any body is parsed. ValidationPipe turns the typed DTO in. The handler is thin: it calls the service and returns. The Service runs the business operation inside dataSource.transaction, sharing the transactional EntityManager so every write commits or rolls back together. The interceptor wraps the response in the envelope and records timing on the way out. The AllExceptionsFilter shapes anything that throws at any stage.
// orders.controller.ts — thin edge, no business logic
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
@Roles('customer')
create(@Body() dto: CreateOrderDto, @CurrentUser() user: AuthUser) {
return this.orders.place(dto, user.id); // DTO already validated by the global pipe
}
}
// orders.service.ts — rules live here, persistence runs in one transaction
@Injectable()
export class OrdersService {
constructor(private readonly dataSource: DataSource) {}
place(dto: CreateOrderDto, userId: string): Promise<OrderView> {
return this.dataSource.transaction(async (manager) => {
const repo = manager.getRepository(Order); // typed Repository<Order>
const stock = await manager.findOne(Product, { where: { id: dto.productId } });
if (!stock || stock.qty < dto.quantity) throw new ConflictException('out of stock');
const order = repo.create({ userId, ...dto });
await repo.save(order); // same tx as the stock read
return toOrderView(order); // domain -> response DTO
});
}
}The handler never sees a transaction, a role check, or an error shape — those live in their lifecycle slots. The service never sees HTTP. That separation is the whole point: each stage references the right earlier lesson — guards for authz, the pipe for validation, the shared transactional manager for persistence, the interceptor for the envelope, the filter for errors.
Types at the edges, fail-fast at boot
The contract of this service is its types, enforced where data crosses a boundary. Three DTOs, never one: a request DTO (validated input), a domain entity (what the database holds), and a response DTO (what callers see — never leak the entity). Config is typed too: registerAs plus ConfigType gives the service a strongly-typed slice instead of stringly-typed process.env lookups, and validate rejects a bad environment at boot rather than at first use.
// config/database.config.ts
import { registerAs, ConfigType } from '@nestjs/config';
export const databaseConfig = registerAs('db', () => ({
url: process.env.DATABASE_URL!,
poolSize: Number(process.env.DB_POOL_SIZE ?? 10),
}));
export type DatabaseConfig = ConfigType<typeof databaseConfig>;
// somewhere in a provider:
constructor(@Inject(databaseConfig.KEY) private readonly db: DatabaseConfig) {}
// db.poolSize is number, db.url is string — no `as`, no `process.env` at call sitesThe production-readiness checklist is the senior judgment that separates “compiles” from “trust on call”:
| Concern | Production-ready answer | The failure it prevents |
|---|---|---|
| Config | Env validated at boot (validate); secrets via config, never in a JWT | Missing var crashes at first use in prod, not at deploy |
| Schema | Migrations only; synchronize: false | synchronize: true silently alters/drops columns on restart |
| Lifecycle | Graceful shutdown (enableShutdownHooks); readiness gates traffic | In-flight requests dropped; traffic routed before deps are warm |
| Health | Liveness = process up; readiness = DB/deps reachable | Liveness that checks the DB triggers a restart loop on a DB blip |
| Observability | Structured logs with a correlation id per request | No way to trace one request across services post-incident |
| Idempotency | Idempotent handlers where at-least-once delivery applies | A retried message double-charges or double-creates |
Together, these six items form a single fail-fast strategy: the earlier a misconfiguration surfaces — at boot rather than at checkout, at the load-balancer rather than in a running pod — the smaller the blast radius. Miss any one of them and you own that specific on-call night.
The testing strategy mirrors the layering: unit-test services with mocked repositories — fast, no DB, asserting the business rules in isolation — and e2e-test the wired pipeline with a real test database and a real token, so the guard, pipe, transaction, interceptor, and filter are all exercised exactly as in production.
▸Why this works
Why split liveness from readiness instead of one /health? They answer different questions to different watchers. Liveness answers “is this process wedged?” — if it fails, the orchestrator restarts the pod, so it must depend only on the process itself, never on the database. Readiness answers “should traffic come here right now?” — if it fails, the load balancer stops routing but does not restart, so it is the right place to check the DB and downstream deps. Collapse them into one probe that pings the database and a brief DB outage becomes a restart storm: every pod fails liveness at once, all restart, and the now-cold connection pools make the outage worse.
You are structuring the order-placement feature so it stays maintainable and testable as the team and the rules grow. How do you arrange the code?
In the finished design, in what order do the cross-cutting components touch one successful POST /orders request?
Why bind the global ValidationPipe with { whitelist: true, transform: true } via APP_PIPE rather than new-ing it in main.ts, and why does whitelist matter?
- 01Describe the module and layer skeleton of the finished service, and where each cross-cutting concern binds.
- 02Walk one POST /orders request through the fixed lifecycle, and name the production-readiness items that keep it trustworthy.
The capstone assembles the whole track into one service you would trust on call. The skeleton is feature modules (OrdersModule, UsersModule) over a fixed internal layering — Controller (thin HTTP edge), Service (the only home of business rules and the owner of the transaction), Repository (typed Repository<T>) — plus a single core module that binds every cross-cutting concern once via DI-aware tokens: APP_GUARD for authn/authz, APP_PIPE for a global ValidationPipe with whitelist and transform, APP_INTERCEPTOR for the response envelope and timing, APP_FILTER for the one error shape. The fixed request lifecycle is the spine: guard -> guard -> pipe -> handler -> service-in-transaction -> interceptor -> filter, each stage referencing its earlier lesson. The contract is types at the edges — a request DTO, a domain entity, and a response DTO, never collapsed into one, with ConfigType and a typed Repository removing stringly-typed lookups. Production-readiness is the senior judgment: validate env at boot to fail fast, migrations only with synchronize off, graceful shutdown with readiness gating traffic, liveness that never touches the DB, structured logs with a correlation id, secrets in config and not in tokens, and idempotent handlers where delivery is at-least-once. Test the layering the way you built it: unit-test services with mocked repositories, e2e-test the wired pipeline against a real DB with real auth. Now when you see a controller doing a role check, a service opening a raw query, or a synchronize: true in a TypeORM config, you know exactly which slot is missing and where to move it.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.