Architecture
Layered APIs, ports and adapters (hexagonal), and tactical Domain-Driven Design in NestJS services.
Layered (N-tier) layout
Layered architecture stacks responsibilities—typically presentation, application logic, and persistence—with explicit rules about which layer may call which. In NestJS, the default resource scaffold maps naturally to a three-layer shape: controllers (HTTP), services (orchestration and rules), and a persistence layer you add (ORM, repositories, or clients).
When it fits: medium-to-large HTTP APIs where predictable request flow and team familiarity with Nest examples matter more than strict domain isolation.
Practices:
- Keep controllers thin: validate and map DTOs, delegate to application services, avoid reaching into ORM details when a repository or port is available.
- Introduce an explicit data-access boundary before the feature surface grows wide.
Trade-offs: strong maintainability and clear security boundaries relative to ad hoc code; for rich domains and many integrations, ports and adapters often scale better than a flat three-layer folder per feature.
Example: controller, service, repository
// items.controller.ts
import { Body, Controller, Post } from "@nestjs/common";
import { ItemsService } from "./items.service";
import { CreateItemDto } from "./dto/create-item.dto";
@Controller("items")
export class ItemsController {
constructor(private readonly items: ItemsService) {}
@Post()
create(@Body() dto: CreateItemDto) {
return this.items.create(dto);
}
}// items.service.ts
import { Injectable } from "@nestjs/common";
import { ItemsRepository } from "./items.repository";
import { CreateItemDto } from "./dto/create-item.dto";
@Injectable()
export class ItemsService {
constructor(private readonly items: ItemsRepository) {}
create(dto: CreateItemDto) {
return this.items.insert(dto);
}
}// items.repository.ts — persistence boundary (illustrative)
import { Injectable } from "@nestjs/common";
import { CreateItemDto } from "./dto/create-item.dto";
@Injectable()
export class ItemsRepository {
async insert(dto: CreateItemDto) {
return { id: "generated-id", ...dto };
}
}Hexagonal (ports and adapters)
Hexagonal architecture isolates core behavior from infrastructure (databases, brokers, email, HTTP clients) by interacting only through ports—contracts owned by the application or domain—implemented by adapters in an outer layer. Dependency inversion keeps vendor-specific code at the edge so the core stays testable and stable as integrations change.
When it fits: complex domains, frequent changes to persistence or messaging, or teams combining DDD, CQRS, or event-centric workflows with clear boundaries.
Suggested module layout
| Area | Responsibility |
|---|---|
application | Use cases, command/query handlers, facades; depends on ports, not concrete drivers. |
domain | Entities, value objects, aggregates, domain events; domain language only. |
infrastructure | Adapters implementing ports (Mongo, Kafka, SMTP, and so on). |
presenters (or interfaces) | HTTP controllers, gateways, GraphQL resolvers—delivery mechanisms. |
Onion architecture is closely related: same inward dependency direction with different naming. Pick one vocabulary for the team and apply it consistently.
Port, use case, adapter, and module binding
// application/ports/items.repository.ts
export const ITEMS_REPOSITORY = Symbol("ITEMS_REPOSITORY");
export interface ItemsRepository {
findById(id: string): Promise<{ id: string; name: string } | null>;
}// application/get-item.use-case.ts
import { Inject, Injectable } from "@nestjs/common";
import { ITEMS_REPOSITORY, ItemsRepository } from "./ports/items.repository";
@Injectable()
export class GetItemUseCase {
constructor(@Inject(ITEMS_REPOSITORY) private readonly items: ItemsRepository) {}
execute(id: string) {
return this.items.findById(id);
}
}// presenters/http/items.controller.ts
import { Controller, Get, Param } from "@nestjs/common";
import { GetItemUseCase } from "../../application/get-item.use-case";
@Controller("items")
export class ItemsController {
constructor(private readonly getItem: GetItemUseCase) {}
@Get(":id")
findOne(@Param("id") id: string) {
return this.getItem.execute(id);
}
}// infrastructure/mongo-items.repository.ts
import { Injectable } from "@nestjs/common";
import { ItemsRepository } from "../application/ports/items.repository";
@Injectable()
export class MongoItemsRepository implements ItemsRepository {
async findById(id: string) {
return { id, name: "demo" };
}
}// items.module.ts
import { Module } from "@nestjs/common";
import { ItemsController } from "./presenters/http/items.controller";
import { GetItemUseCase } from "./application/get-item.use-case";
import { ITEMS_REPOSITORY } from "./application/ports/items.repository";
import { MongoItemsRepository } from "./infrastructure/mongo-items.repository";
@Module({
controllers: [ItemsController],
providers: [GetItemUseCase, { provide: ITEMS_REPOSITORY, useClass: MongoItemsRepository }],
})
export class ItemsModule {}Trade-offs: improved testability and swappable adapters versus more files and indirection early in a project.
Tactical Domain-Driven Design
Strategic DDD (bounded contexts, context maps) sets the big picture; tactical patterns are the code-level vocabulary: entities, value objects, aggregates, repositories, factories, domain services, and domain events.
When it fits: non-trivial invariants, consistency boundaries that span fields, or preparation for event-driven and event-sourced designs.
Building blocks
- Entities: identity and lifecycle; equality by identifier, not full attribute snapshots.
- Value objects: immutable, compared by value; use for money, labels, ranges, and similar concepts.
- Aggregates: consistency cluster; mutations enforce invariants inside one logical transaction boundary.
- Repositories: load and persist aggregates without leaking storage primitives into the domain.
- Factories: encapsulate complex construction when constructors would become unwieldy.
- Domain services: behavior that does not belong on a single entity or value object—use sparingly to avoid an anemic domain model where all logic lives in procedural services.
Value object and entity (illustrative)
// domain/money.vo.ts
export class Money {
private constructor(
public readonly amount: number,
public readonly currency: string,
) {}
static usd(amount: number) {
if (amount < 0) throw new Error("Amount must be non-negative");
return new Money(amount, "USD");
}
equals(other: Money) {
return this.amount === other.amount && this.currency === other.currency;
}
}// domain/account.entity.ts
import { Money } from "./money.vo";
export class Account {
private constructor(
public readonly id: string,
private balance: Money,
) {}
static open(id: string) {
return new Account(id, Money.usd(0));
}
deposit(amount: Money) {
if (amount.currency !== this.balance.currency) {
throw new Error("Currency mismatch");
}
this.balance = Money.usd(this.balance.amount + amount.amount);
}
}Practices: define aggregate roots before event streams; validate value objects at construction; publish domain events through ports when crossing layers.
Related
- Dependency injection — scopes, context trees, and tenancy.
- Event-driven patterns — buses, CQRS, sourcing, sagas.
- Extensibility and tooling — CLI scaffolding and configurable modules.
