← Back to the section

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 status field 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 by dependency-cruiser in 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 through useFactory in the app/ 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-validator are adapter details, not core/.
  • 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.