Warming up the neural circuits...
By the end of this chapter you will:
Every layer in NestJS has exactly one job. The moment you blur the lines — in a controller, HTTP types in a service — the whole system becomes untestable and hard to change.
Before you write any code, trace the path a request takes from the moment it arrives to the moment a response goes out.
Each box has exactly one job. The moment you put SQL in the controller or HTTP knowledge in the service, you've crossed a layer boundary.
A guard answers one question: "Is this request allowed to proceed?"
The most common guard is JwtGuard, which:
Authorization: Bearer <token> headerreq.user// Apply to a whole controller:
@Controller('orders')
@UseGuards(JwtGuard)
export class OrdersController { ... }
// Apply to a single method:
@Post()
@UseGuards(JwtGuard)
async create() { ... }If no token is provided, JwtGuard returns HTTP 401 before the controller method ever runs.
The controller's job:
Nothing more. No business logic. No SQL. No if/else decisions.
@ApiTags('orders')
@ApiBearerAuth()
@Controller({ path: 'orders', version: '1' })
@UseGuards(JwtGuard)
export class OrdersController {
constructor(private readonly ordersService:
@CurrentUser() is a custom decorator in src/common/decorators/. It reads req.user. Use it everywhere instead of @Req() req.
ParseUUIDPipe on :id means if someone calls GET /orders/not-a-uuid, they get a 400 instantly — no DB query is made.
The service's job:
The service knows NOTHING about HTTP — no req, res, no status codes, no headers.
@Injectable()
export class OrdersService {
private readonly logger = new Logger(OrdersService.name);
constructor(
@InjectModel(Order) private readonly orderModel: typeof Order,
private readonly paymentsService
| Rule | Why |
|---|---|
| ≤ 6 injected dependencies | More means you're doing too much — split into two services |
No HTTP types (Request, Response, status codes) | Services don't know they're called over HTTP |
| Always translate Sequelize errors to NestJS exceptions | A UniqueConstraintError becoming a 500 is a controller bug |
| Wrap multi-write operations in a | If order_items insert fails, the order row must also roll back |
Structured logs only — logger.log('event', { key: value }) | Never console.log or string concatenation |
A module groups everything related to one bounded context:
// src/modules/orders/orders.module.ts
@Module({
imports: [
SequelizeModule.forFeature([Order, OrderItem]), // register models
RestaurantsModule, // import module that provides RestaurantsService
],
controllers: [OrdersController],
| Rule | Why |
|---|---|
| A module owns its models | No other module should call OrderModel.create() directly |
| Export the minimum surface | Export OrdersService, not Order model or OrdersRepository |
| No circular imports | If A needs B and B needs A, extract a shared module C |
When creating an order, do you also need to send an email, notify the restaurant, and update user stats? Don’t add all those service calls to OrdersService.create() — that would give it 8 dependencies and make it untestable.
Use events instead:
// In OrdersService — just announce what happened
this.eventEmitter.emit('order.placed', new OrderPlacedEvent(order));// In EmailService — reacts to the event independently
@OnEvent('order.placed')
async sendConfirmation(event: OrderPlacedEvent) {
await this.mailer.sendOrderConfirmation(event.order);
}Now OrdersService has no idea who reacts to its events. Adding a new listener never requires touching OrdersService.
NestJS uses Dependency Injection. Instead of a service creating its own dependencies with new, they are declared in the constructor and NestJS provides them.
@Injectable()
export class OrdersService {
constructor(
// NestJS reads this constructor and automatically provides:
@InjectModel(Order) private readonly orderModel: typeof Order,
private readonly paymentsService: PaymentsService,
private readonly sequelize: Sequelize
When NestJS starts up, it reads all @Module definitions, builds a dependency tree, and creates instances in the right order. You never call new OrdersService(...) yourself.
Why this is powerful: In tests, you can replace WalletService with a mock:
const module = await Test.createTestingModule({
providers: [
OrdersService,
{ provide: WalletService, useValue: { debit: jest.fn() } }, // mock!
{ provide: getModelToken(Order
The controller is a thin HTTP adapter. The service owns all business decisions. If you're writing an if statement in the controller or touching req/res in the service, you're in the wrong layer.