← Back to the section

In Java the compiler guards the Hexagonal architecture: if core/ accidentally references Spring, the build fails. In Node.js there is no such protection — TypeScript does not forbid importing anything from anywhere. A folder structure without an extra tool is just a convention that is easy to break.

This article explains: exactly how to lay out the files, what goes where, and how to set up an automatic check of the boundaries between layers with dependency-cruiser.

The four zones of a project

In a Hexagonal NestJS project all the code splits into four zones inside src/:

src/
  core/       # business logic — knows nothing about NestJS, TypeORM, axios
  adapters/   # input (HTTP, Kafka) and output (DB, external APIs)
  app/        # the assembly point — puts everything together

The dependency arrow is strictly one-way:

app → adapters → core

core/ knows nothing about adapters/ or app/. The adapters/ do not know about each other. app/ knows about all of them — it is the one that ties the parts together.

core/ — business logic without infrastructure

Here lives everything that belongs to the problem domain: aggregates, value objects, domain events, port interfaces, commands, and handlers.

core/
  order/
    aggregate/        # Order, OrderItem
    value-object/     # OrderId, Money, CustomerId
    event/            # OrderCreatedEvent, OrderConfirmedEvent
    port/
      out/            # OrderRepository, PaymentPort — interfaces + Symbol tokens
    usecases/         # CreateOrderCommand, CreateOrderHandler
  product/
    aggregate/
    port/out/
    usecases/
  shared/
    dispatcher.ts     # the single entry point into the core
    value-object/     # Money, Clock, TransactionRunner

The main constraint of core/: @nestjs/*, typeorm, class-validator, axios do not appear here. An aggregate class is just a TypeScript class with no third-party dependencies:

// core/order/aggregate/order.ts
import { randomUUID } from 'node:crypto';
import { OrderId } from '../value-object/order-id';
import { Money } from '../../shared/value-object/money';
import { OrderStatus } from '../value-object/order-status';
import { OrderCreatedEvent } from '../event/order-created.event';

export class Order {
  private constructor(
    readonly id: OrderId,
    private _totalAmount: Money,
    private _status: OrderStatus,
    private readonly _events: unknown[],
  ) {}

  static create(customerId: CustomerId, items: OrderItem[], clock: Clock): Order {
    const id = new OrderId(randomUUID());
    const total = items.reduce((acc, i) => acc.add(i.price), Money.zero());
    const order = new Order(id, total, OrderStatus.PENDING, []);
    order._events.push(new OrderCreatedEvent(id, customerId, total, clock.now()));
    return order;
  }

  confirm(): void {
    if (this._status !== OrderStatus.PENDING) {
      throw new OrderAlreadyConfirmedError(this.id);
    }
    this._status = OrderStatus.CONFIRMED;
  }

  pullEvents(): unknown[] { return this._events.splice(0); }
}

Not a single import from the framework. Code like this can be tested without spinning up NestJS.

Handlers — plain classes without @Injectable

A typical mistake is to put @Injectable() on a handler in core/. Then the handler becomes dependent on @nestjs/common, which breaks the isolation.

Handlers in core/ are plain TypeScript classes. NestJS knows nothing about them. The wiring happens in app/ through useFactory providers:

// core/order/usecases/create-order.handler.ts
export class CreateOrderHandler {
  constructor(
    private readonly orders: OrderRepository,
    private readonly tx: TransactionRunner,
    private readonly clock: Clock,
  ) {}

  async handle(cmd: CreateOrderCommand): Promise<Order> {
    const order = Order.create(cmd.customerId, cmd.items, this.clock);
    await this.tx.run(async () => { await this.orders.save(order); });
    return order;
  }
}

No @Injectable(). No @Inject(). No dependency on @nestjs/common.

adapters/ — input and output

Adapters are the implementation of what core/ describes through interfaces. They split into inbound (in/) and outbound (out/).

Inbound adapters are the entry points into the application:

adapters/in/
  http/            # public REST, JWT with user audience
  http-admin/      # REST for the admin panel, JWT with admin audience
  kafka/           # Kafka consumers as an entry point
  cli/             # CLI / batch (if any)

The public API and the admin API are separated deliberately. Each has its own Guard, its own JwtStrategy, its own DTOs. Mixing them in one folder means losing that isolation.

Outbound adapters are calls to external systems. Each system gets its own folder:

adapters/out/
  persistence/     # TypeORM — implements OrderRepository
  sber/            # Sber's axios client — implements PaymentPort
  notifications/   # SMS provider — implements NotificationPort
  kafka/           # kafkajs — implements DomainEventPublisher

Why one folder per system: the timeout, retry, and circuit breaker for Sber and for SMS are different settings. A single shared HTTP client for all external calls is a common mistake that makes separate control of behavior impossible.

app/ — the assembly point

app/ is where everything comes together. Only here do the NestJS modules bind ports to implementations:

app/
  main.ts           # NestFactory.create, enableShutdownHooks
  app.module.ts     # AppModule: imports all feature modules
  config/           # ConfigModule, typed config
  Dockerfile

There is no business logic in app/ — only assembly. And nobody imports from app/ — it is the closing node.

Example: how a handler from core/ receives its dependencies through useFactory:

// app/order.module.ts
@Module({
  providers: [
    { provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
    { provide: TX_RUNNER, useClass: PgTransactionRunner },
    { provide: CLOCK, useClass: SystemClock },
    {
      provide: CreateOrderHandler,
      useFactory: (repo, tx, clock) => new CreateOrderHandler(repo, tx, clock),
      inject: [ORDER_REPOSITORY, TX_RUNNER, CLOCK],
    },
  ],
  exports: [CreateOrderHandler],
})
export class OrderModule {}

CreateOrderHandler does not know that it lives in a NestJS container. It receives its dependencies through the constructor — like an ordinary object.

dependency-cruiser — an automatic boundary check

A folder structure without a tool is a convention on trust. It takes one developer adding import { DataSource } from 'typeorm' to an aggregate file, and the boundary is broken. The tests pass, the IDE says nothing, code review might miss it.

dependency-cruiser solves this: you describe the forbidden transitions in .dependency-cruiser.cjs and run the check in CI as a mandatory step.

// .dependency-cruiser.cjs
module.exports = {
  forbidden: [
    {
      name: 'core-pure',
      severity: 'error',
      from: { path: '^src/core' },
      to: {
        path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator|axios|kafkajs))',
      },
    },
    {
      name: 'adapters-independent',
      severity: 'error',
      from: { path: '^src/adapters/in' },
      to: { path: '^src/adapters/out' },
    },
    {
      name: 'nobody-depends-on-app',
      severity: 'error',
      from: { path: '^src/(core|adapters)' },
      to: { path: '^src/app' },
    },
  ],
};

Add a script to package.json:

{
  "scripts": {
    "arch:check": "depcruise --validate .dependency-cruiser.cjs src"
  }
}

Run npm run arch:check in CI as a required check — without a green status the merge is blocked. A violation looks like this:

error core-pure: src/core/order/aggregate/order.ts
  → node_modules/@nestjs/common/index.js

You cannot "not notice" it.

Common mistakes

TypeORM in core/: import { DataSource } from 'typeorm' in an aggregate or a handler is an isolation violation. TypeORM belongs only in adapters/out/persistence/; the mapping between domain and ORM objects is in *.mapper.ts.

User and admin in one folder: if the public and administrative REST live in a single adapters/in/http/, they will inevitably start sharing Guards and DTOs. Splitting into http/ and http-admin/ makes this explicit.

@Injectable() on a handler in core/: the handler becomes dependent on @nestjs/common. Use a plain class and a useFactory provider in app/.

An in adapter imports an out adapter: the controller must not reach directly into the repository. Coordination goes through DispatcherHandler in core/.

In short

  • src/ splits into three zones: core/ (business), adapters/ (input/output), app/ (assembly).
  • The dependency arrow: app → adapters → core. core/ knows nothing about the framework or the adapters.
  • Handlers in core/ are plain classes without @Injectable(). Wiring is through useFactory in app/.
  • Each external system gets its own folder in adapters/out/. Each type of input gets its own folder in adapters/in/.
  • dependency-cruiser with three rules (core-pure, adapters-independent, nobody-depends-on-app) run in CI makes the boundaries enforced rather than voluntary.
  • Ports — Symbol tokens, the port interface, port exceptions in core/.
  • Adapters in — NestJS controllers, mapping DTO → command, Dispatcher.
  • Adapters out — implementing port interfaces, TypeORM and axios adapters.
  • Core layer — aggregates, port interfaces, and plain handlers.
  • Architecture tests — the full dependency-cruiser config and a breakdown of errors.