Nest skill is the boundary, not the decorator
In NestJS, controllers, DTOs, services, and persistence each own a line. Using any or returning ORM entities from controllers softens the boundary the same way nested try/catch theater does.
In NestJS, the skill that matters is boundary ownership, not decorators. Controllers own HTTP; DTOs own the wire contract; services own the use case; persistence owns the database. Softening any line with `any` or by returning ORM entities from controllers produces the same failure mode as nested try/catch theater: the shape looks careful, the leak still ships.
The Nest skill that matters is the boundary. Decorators are syntax. Controllers, DTOs, services, and persistence each own a line. What may cross that line is the design. Soften it and you get the same failure mode as nested error theater: the shape looks careful, the leak still lands.
I have watched this play out in Nest apps that look textbook on day one. Modules named cleanly. Providers registered. Folders called domain. Then someone types body: any "just for this endpoint," or returns a Prisma row from a controller because mapping felt like busywork. Six months later every client knows your column names, and a queue worker cannot call the same use case without importing HTTP exceptions. The decorators did their job. The walls did not.
Four layers
| layer | owns | may accept | may return |
|---|---|---|---|
| controller | HTTP verbs, status codes, route params | DTO / validated body | response DTO or mapped view |
| DTO | shape at the wire | primitives and nested DTOs | itself, after ValidationPipe |
| service | use cases, rules, orchestration | domain inputs (ids, commands) | domain results, not HTTP objects |
| persistence | queries, writes, transactions | ids, filters, write models | entities or rows — stop here |
A request walks down. A response walks up. Each step should transform. If a step only forwards, the next layer inherits someone else's concerns — HTTP status codes inside a pricing rule, or a React form coupled to createdAt nullability from the generator.
Think of it like a handoff. The controller understands the request. The service decides what should happen. Persistence talks to the store. When one method does all three, reviews get harder and swaps get expensive. Nest makes the wiring cheap. It does not invent the handoffs for you.
Controllers stop at HTTP
Controllers map HTTP into calls. They do not invent business rules. They do not reach for prisma or a repository. They take a validated DTO and hand a command or id to a service.
@Controller('orders')
export class OrdersController {
constructor(private readonly orders: OrdersService) {}
@Post()
create(@Body() dto: CreateOrderDto): Promise<OrderResponseDto> {
return this.orders.create({
customerId: dto.customerId,
sku: dto.sku,
qty: dto.qty,
});
}
}That return type is a response DTO, not a Prisma Order. The service can use Prisma. The controller never sees the table row shape. Boring on purpose: a schema rename should not become an accidental API break.
Status codes belong here. @HttpCode, @Res({ passthrough: true }), or throwing Nest HTTP exceptions are controller concerns. A service that throws BadRequestException has crossed the line the other way: domain logic now knows Express status codes. Call the same rule from a CLI or Bull worker and you either fake HTTP or rewrite. Prefer domain outcomes the controller can map — a Result or typed error union — so 404 stays out of the core.
DTOs are the contract at the edge
A DTO is not a dump of fields you might need someday. It is the allowlist for one operation. In a real Nest codebase that means you feel a little duplication across create and patch DTOs, and that is fine. The alternative is one blob that accepts half of create and all of update and somehow also list filters.
export class CreateOrderDto {
@IsUUID()
customerId!: string;
@IsString()
@MinLength(1)
sku!: string;
@IsInt()
@Min(1)
qty!: number;
}ValidationPipe with whitelist: true and forbidNonWhitelisted: true makes that allowlist real. Extra keys die at the edge. Without the pipe, the class is documentation that TypeScript erases at runtime — which is how you get a handler that "types" as CreateOrderDto while accepting whatever JSON arrived.
Do not reuse one mega-DTO for create, update, and list filters. Create needs required fields. Patch needs partials. List needs pagination and sort. Shared base classes are fine when fields truly match. Shared "god" DTOs are how soft boundaries start: one class accumulates optional everything, and nobody remembers which fields are required for which verb.
Services own the use case
Services take domain inputs. They return domain results. They may call other services and repositories. They should not import @nestjs/common HTTP types for control flow. When I open a service file, I want the business story — who can order what, what pending means — not status-code branches that only make sense under Express.
@Injectable()
export class OrdersService {
constructor(private readonly repo: OrderRepository) {}
async create(cmd: CreateOrderCommand): Promise<OrderView> {
const row = await this.repo.insert({
customerId: cmd.customerId,
sku: cmd.sku,
qty: cmd.qty,
status: 'pending',
});
return toOrderView(row);
}
}CreateOrderCommand can be a plain type or a class. The point is: it is not CreateOrderDto with class-validator decorators hanging off it into the domain. Mapping at the controller keeps the service free of pipe concerns. You can unit-test the use case with a plain object and never spin up ValidationPipe.
If the service needs a transaction, open it in the service or a unit-of-work helper — not in the controller. Controllers should not hold PrismaClient long enough to call $transaction. Once the controller owns the client, every "quick" endpoint becomes a reason to skip the repository, and the persistence layer dies one method at a time.
Persistence stops at the repository
Repositories (or thin Prisma wrappers) speak table language. Controllers and most services should not. Export a small API:
@Injectable()
export class OrderRepository {
constructor(private readonly prisma: PrismaClient) {}
insert(data: OrderInsert): Promise<OrderRow> {
return this.prisma.order.create({ data });
}
findById(id: string): Promise<OrderRow | null> {
return this.prisma.order.findUnique({ where: { id } });
}
}Map OrderRow to OrderView before anything leaves the application layer. Boring maps survive schema changes. Returning the Prisma type from the controller couples every client to createdAt, relations you forgot to include, and generator nullability quirks. I have seen frontends grow as any around those quirks because "the API already sends it that way." The wall was gone; TypeScript stayed green.
TypeORM entities have the same problem. @Column decorators are persistence metadata, not an API contract. Shipping the entity over HTTP feels fast until a soft-delete flag or audit column shows up in a mobile client you forgot about.
Soft boundaries look like structure
These all feel like Nest code. They all erase the layers:
any on the body. The controller compiles. The service accepts whatever JSON arrived. You rediscover required fields in production — usually after a client ships a typo and the happy path still returns 201 with half a row.
// soft
@Post()
create(@Body() body: any) {
return this.orders.create(body);
}Leaked entities. The controller returns Promise<Order> where Order is the Prisma model. OpenAPI and frontend types now track your schema. A rename of qty to quantity becomes a breaking HTTP change without a version bump you chose.
Service imports HTTP exceptions as business outcomes. throw new NotFoundException() inside a pricing rule means every caller must be an HTTP entrypoint. CLI jobs and queue workers inherit status-code thinking. That is fine for a weekend prototype. It is expensive once you have two entrypoints.
Controller queries. @Inject(PrismaClient) on a controller "just for this endpoint" is how the repository layer dies one method at a time. The next person copies the pattern because the precedent is already in the tree.
Nested try/catch theater fails the same way. Three catch blocks that log and rethrow look like care. They do not define who owns the error. Soft Nest boundaries look like care: modules, providers, folders named domain. The leak is still there — any, entity return types, HTTP types in the core.
What may cross each line
- HTTP → controller: raw request. Immediately into a DTO via the pipe.
- Controller → service: command or query object. No
Request, no headers unless you already extracted what you need. - Service → persistence: ids, filters, write models. No Express objects.
- Persistence → service: rows or domain aggregates you map locally.
- Service → controller: views or Result types the controller can turn into status + body.
- Controller → HTTP: response DTO or explicit status. Never the ORM class.
Cross the other direction only with intent. A service that needs the request id for logging should take a string, not @Req(). Pull the value at the edge, pass the primitive inward. That keeps the use case callable from places that never saw Express.
A short audit
Pick one module. Trace one POST from route to database and back. Do it with the file tree open, not from memory — the soft spots hide in the methods that felt urgent.
- Does the controller type the body as a DTO class that ValidationPipe actually runs?
- Does the service signature mention Prisma or TypeORM types?
- Does any controller method return an entity class?
- Do HTTP exceptions originate only at the controller (or a dedicated mapper), not deep in pure rules?
- Can you swap Prisma for another store without editing controllers?
Any "no" is a soft boundary. Fix the line, not the folder names. Renaming services to application does not stop an entity from leaving the building.
Nest rewards people who treat layers as real. Decorators make the wiring cheap. They do not invent the walls. You still have to keep them — in reviews, in the types you export, and in the boring maps that look like overhead until the schema moves and the API does not.
Related
- Validation is a gate — ValidationPipe before the service
- Types that stop at the wall — branded IDs, Results, and don't return ORM entities