As a NestJS application grows, the business logic starts to sprawl — a bit in the controller, a bit in a service, a bit in a TypeORM repository. When it is time to write a test, it turns out that nothing works without a running database. When you need to switch HTTP to Kafka, you have to rewrite half the code.
Hexagonal Architecture solves this with a single principle: the business logic does not know where the data came from or where it will go. To achieve that, the application carves out core/ — a layer that imports neither NestJS, nor TypeORM, nor axios. This is where the aggregates, rules, invariants, and contracts to the outside world live.
What you can import into core/
The rule is simple: only TypeScript and small utilities without side effects.
import { v4 as uuidv4 } from 'uuid'; // identifier generation
import Big from 'big.js'; // precise arithmetic
import { parseISO } from 'date-fns'; // working with dates
Everything else is outside core/:
@nestjs/*— the framework, decorators, the DI container;typeorm, Entity decorators — data-storage details;class-validator,class-transformer— decorators for HTTP requests;axios,undici,node-fetch— HTTP clients;kafkajs,bullmq,ioredis— queues and caches.
If such an import appears in core/, either the file is in the wrong place or something has broken in the architecture. The boundary is checked automatically through dependency-cruiser in CI, not by hand.
How core/ is organized
src/core/
└── order/ # a single Bounded Context
├── aggregate/
│ └── order.ts # Aggregate Root
├── entity/
│ └── order-item.ts
├── value-object/
│ ├── money.ts
│ └── order-id.ts
├── event/
│ └── order-confirmed.event.ts # Domain Event
├── port/out/
│ ├── order-repository.ts # interface + Symbol token
│ └── payment-port.ts
└── usecases/
├── confirm-order.command.ts
├── confirm-order.handler.ts
├── get-order.query.ts
└── get-order.handler.ts
aggregate/— the aggregate root with the business rules;port/out/— contracts to the outside world: repositories, external-system clients, event publishers;usecases/— "command/query + handler" pairs that orchestrate an operation.
A single service usually contains one to three Bounded Contexts. More is a signal that it is time to split the services.
The port interface and the Symbol token
The contract between core/ and an adapter is expressed as a TypeScript interface. But there is a catch: TypeScript erases interfaces at runtime, and NestJS DI works precisely at runtime. So a Symbol token is declared alongside the interface — it is the "name of the slot" in the container.
// core/order/port/out/order-repository.ts
export const ORDER_REPOSITORY = Symbol('OrderRepository');
export interface OrderRepository {
findById(id: OrderId): Promise<Order | null>;
findByIdForUpdate(id: OrderId): Promise<Order>; // throws OrderNotFoundError if not found
save(order: Order): Promise<void>;
}
// core/payment/port/out/payment-port.ts
export const PAYMENT_PORT = Symbol('PaymentPort');
export interface PaymentPort {
register(cmd: RegisterPayment): Promise<RegisterResult>;
cancel(paymentId: PaymentId): Promise<void>;
}
core/ knows only the interface — what can be asked of the outside world. Which implementation shows up (a real DB, Kafka, a stub in a test) is decided by app/. That is exactly what lets you test handlers without a database.
findByIdForUpdate returns the aggregate or throws — because a missing order during confirmation is an error, not a normal scenario. findById returns null only where absence is an acceptable result (for example, a lookup by an optional parameter).
Rich domain — logic inside the aggregate
A common mistake in NestJS projects: the aggregate is just a bag of fields with getters and setters, while all the logic lives in OrderService. This is called an anemic model, and it has concrete consequences:
- the rule "an order cannot be confirmed without items" is copied into the controller, into the Kafka consumer, into a migration script — sooner or later the copies diverge;
- you cannot write a unit test for the logic without a running NestJS context with a database;
- understanding the order's lifecycle means finding all the places where the
statusfield changes.
The alternative: the business rules and invariants live inside the aggregate.
// core/order/aggregate/order.ts
export class Order {
private status: OrderStatus;
private items: OrderItem[];
private total: Money;
private readonly events: DomainEvent[] = [];
confirm(): void {
if (this.items.length === 0) {
throw new EmptyOrderError(this.id);
}
if (this.status !== OrderStatus.DRAFT) {
throw new InvalidOrderStatusError(this.status, OrderStatus.DRAFT);
}
if (this.total.isZeroOrNegative()) {
throw new InvalidOrderTotalError(this.total);
}
this.status = OrderStatus.CONFIRMED;
this.events.push(new OrderConfirmedEvent(this.id, this.total));
}
addItem(product: ProductId, qty: number, price: Money): void {
if (this.status !== OrderStatus.DRAFT) {
throw new InvalidOrderStatusError(this.status, OrderStatus.DRAFT);
}
this.items.push(new OrderItem(product, qty, price));
this.total = this.total.add(price.multiply(qty));
}
pullEvents(): DomainEvent[] {
return this.events.splice(0);
}
}
The handler, meanwhile, stays thin — it orchestrates the operation rather than deciding business questions:
// core/order/usecases/confirm-order.handler.ts
export class ConfirmOrderHandler {
constructor(
private readonly orders: OrderRepository,
private readonly payments: PaymentPort,
private readonly tx: TransactionRunner,
) {}
async handle(cmd: ConfirmOrderCommand): Promise<void> {
await this.tx.run(async () => {
const order = await this.orders.findByIdForUpdate(cmd.orderId);
order.confirm(); // all the logic is in the aggregate
await this.orders.save(order);
const events = order.pullEvents();
for (const e of events) {
await this.payments.processEvent(e); // the implementation is behind the port interface
}
});
}
}
The handler is a plain class: no decorators, no @Injectable, just a constructor with dependencies through interfaces. You can create it with new in a test and pass in stubs.
Why @Injectable is forbidden in core/
Java has a mechanism that lets you pick up classes without Spring on the classpath. Node has nothing of the sort. If you add @Injectable in core/, you have to import @nestjs/common — and that is already a layer-boundary violation.
Instead, handlers are wired through useFactory in the application module:
// app/order.module.ts
import { Module } from '@nestjs/common';
import { ConfirmOrderHandler } from '../core/order/usecases/confirm-order.handler';
import { ORDER_REPOSITORY, TX_RUNNER, PAYMENT_PORT } from '../core/order/port/out';
@Module({
providers: [
{
provide: ConfirmOrderHandler,
useFactory: (
orders: OrderRepository,
tx: TransactionRunner,
payments: PaymentPort,
) => new ConfirmOrderHandler(orders, tx, payments),
inject: [ORDER_REPOSITORY, TX_RUNNER, PAYMENT_PORT],
},
],
exports: [ConfirmOrderHandler],
})
export class OrderModule {}
This is more verbose than @Injectable, but it gives transparency: each useFactory explicitly names what is bound where. NestJS catches an unregistered token at application startup, not on the first request.
On a service with 20–30 handlers, useFactory looks bulky. But it is a manageable bulk — every line carries meaning, and it is easier to make sense of than "magic" DI through reflection.
Common mistakes
@Entity() from TypeORM in core/. A TypeORM Entity is a detail of the persistence adapter. In core/ it is a plain TypeScript class without decorators. The mapping between them is in the adapter.
A REST DTO with class-validator in core/. CreateOrderDto with @IsString() decorators is a detail of the HTTP adapter. In core/ it is a CreateOrderCommand with domain types.
A port as a class with an implementation. A port is only an interface + a Symbol token. If the port is a class, you cannot substitute it in a test without changing core/.
All the logic in OrderService, the aggregate is just getters. This is an anemic model. Move the logic into the aggregate — the invariants gather in one place and the tests get simpler.
In short
core/is a layer without frameworks: only TypeScript, stdlib, and small utilities. The boundary is checked bydependency-cruiserin CI.- A port interface is declared together with a Symbol token: TypeScript erases interfaces at runtime, and NestJS DI works by token.
- Handlers are plain classes without
@Injectable; they are wired throughuseFactoryin theapp/modules. - Business rules live in the aggregate (
order.confirm()), not in service classes — that way invariants are not copied and can be tested without the framework. - A TypeORM Entity and a DTO with
class-validatorare adapter details, notcore/.
What to read next
- Ports in Hexagonal Architecture: Node/NestJS — port interfaces and Symbol tokens in more detail.
- Adapters in: Node/NestJS — the controller, the mapper request-DTO into a command.
- Adapters out: Node/NestJS — implementing the port, binding by token, mapping domain ↔ persistence.
- Bootstrap / composition root: Node/NestJS —
AppModule, wiring all the ports.