Etsub
Remote / Worldwide  
All writing
10 min readStructure That Survives · Part 1

The Dependency Rule, From the Source

Hexagonal, Onion, and Clean Architecture are three drawings of one rule. Here is what each author actually wrote, and what the rule costs you in a real NestJS codebase.

By , Fullstack Software Engineer

Most "clean architecture" NestJS repositories on GitHub have four folders named domain, application, infrastructure, presentation — and an import graph that goes wherever it likes. Invoice carries @Column(), the use case injects Repository<Invoice> from TypeORM, and the domain layer imports @nestjs/common. The folders are cosmetic. The rule they are supposed to encode is not.

This post goes back to the three primary texts, quotes them, and then shows what enforcing the rule actually costs in NestJS.

Three papers, one rule

Alistair Cockburn, Hexagonal Architecture, HaT Technical Report 2005.02 (4 September 2005). The pattern is stated as an intent, not a folder layout:

"Allow an application to equally be driven by users, programs, automated test or batch scripts, and to be developed and tested in isolation from its eventual run-time devices and databases." — Cockburn, Hexagonal Architecture

Note what is not in that sentence: no layers, no folder names, no framework. Also worth knowing, because half the internet over-reads the diagram:

"The hexagon is not a hexagon because the number six is important, but rather to allow the people doing the drawing to have room to insert ports and adapters as they need, not being constrained by a one-dimensional layered drawing."

Cockburn splits ports into primary (driving) ports — actors that initiate behaviour, like an HTTP controller, a message consumer or a CLI command — and secondary (driven) ports — things your application calls, like a database, a payment gateway or a mail service. That asymmetry is the whole design.

Jeffrey Palermo, The Onion Architecture: Part 1 (29 July 2008). Palermo names the disease before the cure:

"The biggest offender (and most common) is the coupling of UI and business logic to data access." — Palermo, The Onion Architecture: Part 1

And the cure:

"All code can depend on layers more central, but code cannot depend on layers further out from the core. In other words, all coupling is toward the center."

"The database is not the center. It is external."

Read that last line next to a typical Nest project generated from an ORM schema, where the entity classes are the model and every service takes a Repository<T>. That is the shape Palermo is arguing against.

Robert C. Martin, The Clean Architecture (13 August 2012). Martin's article explicitly credits Cockburn's Ports and Adapters, Palermo's Onion, DCI (Coplien and Reenskaug) and BCE (Jacobson), then collapses them into four concentric circles — Entities, Use Cases, Interface Adapters, Frameworks and Drivers — governed by one sentence:

"source code dependencies can only point inwards" … "Nothing in an inner circle can know anything at all about something in an outer circle." — Martin, The Clean Architecture

The interesting part of that article is the bit people skip: control flow and dependency direction disagree, and Martin resolves it explicitly.

"We usually resolve this apparent contradiction by using the Dependency Inversion Principle."

A use case must hand its result to something that lives further out. It cannot call it directly. It calls an interface it owns; the outer layer implements that interface. Every "clean architecture" you will ever read is that trick, repeated.

So: three papers, twenty years apart in vocabulary, one rule. Dependencies point at the domain. Never the reverse.

What the rule buys you

Concretely, three things:

  1. Deferred decisions. Martin, in Screaming Architecture (30 September 2011): "A good software architecture allows decisions about frameworks, databases, web-servers, and other environmental issues and tools, to be deferred and delayed." (source)
  2. Tests that do not need infrastructure. This is Cockburn's stated intent verbatim — "developed and tested in isolation from its eventual run-time devices and databases." In Nest terms: unit tests that never call Test.createTestingModule, never start a container, and run in milliseconds.
  3. A blast radius you can predict. Swapping TypeORM for Prisma, or Stripe for Adyen, touches one adapter folder rather than every file that happened to import the SDK.

What the rule costs you

Be honest about this, because the cost is paid on day one.

  • Mapping code. If the domain does not know your ORM, then ORM entities are not domain objects. Somebody writes toDomain() and toPersistence(). That is the tax, and it is not small.
  • Interface sprawl. Every driven port is an interface plus an injection token. Applied dogmatically to a CRUD service, you get one port per table and zero benefit.
  • Indirection when reading. "Where is this implemented?" becomes a search through providers arrays instead of a click.

The rule pays off where the domain has behaviour worth protecting — pricing, entitlements, billing state machines, anything with invariants. It does not pay off in a thin CRUD proxy over a database. There, the honest architecture is a controller that calls a repository, and pretending otherwise is theatre.

The domain layer: plain TypeScript

The test for a domain file in Nest is mechanical: it imports nothing from @nestjs/*, no ORM, no SDK. If the only imports are other domain files and the standard library, you are inside the circle.

// src/billing/domain/invoice.ts
import { InvalidPaymentDateError, InvoiceAlreadyPaidError } from './errors';
import { Money } from './money';

export class Invoice {
  private constructor(
    readonly id: string,
    readonly customerId: string,
    readonly total: Money,
    readonly issuedAt: Date,
    private paidAt: Date | null,
  ) {}

  /** Rehydration from a persisted state — used by adapters, not by callers. */
  static restore(props: {
    id: string;
    customerId: string;
    total: Money;
    issuedAt: Date;
    paidAt: Date | null;
  }): Invoice {
    return new Invoice(
      props.id,
      props.customerId,
      props.total,
      props.issuedAt,
      props.paidAt,
    );
  }

  // Behaviour lives with the data — this is the point of the whole exercise.
  markPaid(at: Date): void {
    if (this.paidAt) throw new InvoiceAlreadyPaidError(this.id);
    if (at < this.issuedAt) throw new InvalidPaymentDateError(this.id);
    this.paidAt = at;
  }

  get isPaid(): boolean {
    return this.paidAt !== null;
  }
}

Its test needs no framework at all:

// src/billing/domain/invoice.spec.ts
it('refuses a second payment', () => {
  const invoice = Invoice.restore({ ...draft, paidAt: null });
  invoice.markPaid(new Date('2026-07-01'));
  expect(() => invoice.markPaid(new Date('2026-07-02'))).toThrow(
    InvoiceAlreadyPaidError,
  );
});

No TestingModule, no in-memory SQLite, no mocks. That speed is the whole return on the mapping tax.

Ports: interfaces plus tokens

TypeScript interfaces vanish at compile time, so an interface alone cannot be a dependency-injection key. The idiomatic Nest fix is a Symbol token declared next to the interface, both owned by the domain:

// src/billing/domain/invoices.port.ts
import { Invoice } from './invoice';

export interface Invoices {
  byId(id: string): Promise<Invoice | null>;
  save(invoice: Invoice): Promise<void>;
}

export const INVOICES = Symbol('Invoices');
// src/billing/domain/payment-gateway.port.ts
export interface PaymentGateway {
  capture(invoiceId: string, amount: Money): Promise<CaptureResult>;
}

export const PAYMENT_GATEWAY = Symbol('PaymentGateway');

Both are Cockburn's secondary ports: the application drives them. The controller is a primary port — it drives the application, and it is allowed to depend inward on the use case directly.

The use case: orchestration only

// src/billing/application/pay-invoice.usecase.ts
import { Inject, Injectable } from '@nestjs/common';
import { INVOICES, type Invoices } from '../domain/invoices.port';
import { PAYMENT_GATEWAY, type PaymentGateway } from '../domain/payment-gateway.port';
import { InvoiceNotFoundError } from '../domain/errors';

@Injectable()
export class PayInvoice {
  constructor(
    @Inject(INVOICES) private readonly invoices: Invoices,
    @Inject(PAYMENT_GATEWAY) private readonly payments: PaymentGateway,
    private readonly clock: Clock,
  ) {}

  async execute(invoiceId: string): Promise<void> {
    const invoice = await this.invoices.byId(invoiceId);
    if (!invoice) throw new InvoiceNotFoundError(invoiceId);

    await this.payments.capture(invoice.id, invoice.total);
    invoice.markPaid(this.clock.now());   // the rule lives in the domain
    await this.invoices.save(invoice);
  }
}

@Injectable() on the use case is a compromise worth naming: it is a @nestjs/common import in the application layer. Purists push the decorator out via useFactory providers; most teams accept it and keep the domain folder framework-free. Decide once, write it down, and enforce that line — not a vaguer one.

Clock is not ceremony. Injecting time is what makes "invoice cannot be paid before it was issued" testable without freezing the system clock.

The adapter: where the ORM lives

// src/billing/infrastructure/typeorm/invoice.orm-entity.ts
@Entity('invoices')
export class InvoiceOrmEntity {
  @PrimaryColumn('uuid') id: string;
  @Column('uuid') customerId: string;
  @Column('bigint') totalCents: string;
  @Column('varchar', { length: 3 }) currency: string;
  @Column('timestamptz') issuedAt: Date;
  @Column('timestamptz', { nullable: true }) paidAt: Date | null;
}
// src/billing/infrastructure/typeorm/invoice.mapper.ts
export const InvoiceMapper = {
  toDomain(row: InvoiceOrmEntity): Invoice {
    return Invoice.restore({
      id: row.id,
      customerId: row.customerId,
      total: Money.of(BigInt(row.totalCents), row.currency),
      issuedAt: row.issuedAt,
      paidAt: row.paidAt,
    });
  },

  toPersistence(invoice: Invoice): InvoiceOrmEntity { /* … */ },
};
// src/billing/infrastructure/typeorm/typeorm-invoices.repository.ts
@Injectable()
export class TypeOrmInvoices implements Invoices {
  constructor(
    @InjectRepository(InvoiceOrmEntity)
    private readonly rows: Repository<InvoiceOrmEntity>,
  ) {}

  async byId(id: string): Promise<Invoice | null> {
    const row = await this.rows.findOne({ where: { id } });
    return row ? InvoiceMapper.toDomain(row) : null;
  }

  async save(invoice: Invoice): Promise<void> {
    await this.rows.save(InvoiceMapper.toPersistence(invoice));
  }
}

Two decorators, one mapper, and the entire persistence vocabulary — bigint, timestamptz, nullable columns — stays outside the domain.

The module is the composition root

// src/billing/billing.module.ts
@Module({
  imports: [TypeOrmModule.forFeature([InvoiceOrmEntity])],
  controllers: [InvoiceController],
  providers: [
    PayInvoice,
    { provide: INVOICES, useClass: TypeOrmInvoices },
    { provide: PAYMENT_GATEWAY, useClass: StripeGateway },
    { provide: Clock, useClass: SystemClock },
  ],
  exports: [PayInvoice],
})
export class BillingModule {}

This file is the only place that knows TypeORM and Stripe exist for this context. It is also the seam that makes integration tests cheap — overrideProvider(PAYMENT_GATEWAY).useClass(FakeGateway) swaps the outside world without touching a line of domain code.

Two Nest-specific traps worth stating plainly:

  • ORM entities are not domain entities. The moment Invoice carries @Column(), your domain depends on TypeORM and the rule is broken — no folder name will fix it.
  • DTOs belong at the edge. class-validator decorators describe an HTTP payload. Validate the DTO in the controller, build domain input from it, and let the domain enforce invariants that hold regardless of transport. A rule enforced only by @IsPositive() disappears the day a queue consumer calls the same use case.

Making the compiler care

Conventions decay silently, so make the boundary a lint failure. In a single-app Nest repo, import/no-restricted-paths is enough — it defines zones where "target identifies which files are part of the zone" and "from identifies folders from which the zone is not allowed to import" (eslint-plugin-import docs):

// eslint.config.mjs
'import/no-restricted-paths': ['error', {
  zones: [
    {
      target: './src/*/domain',
      from: './src/*/infrastructure',
      message: 'Domain must not depend on infrastructure (the Dependency Rule).',
    },
    {
      target: './src/*/domain',
      from: './node_modules/@nestjs',
      message: 'Domain must stay framework-free.',
    },
  ],
}]

In a Nx workspace the equivalent is @nx/enforce-module-boundaries, which "checks TypeScript imports and package.json dependencies during linting" and expresses the same constraint as tags — for example, projects tagged type:domain may only depend on other type:domain projects (Nx docs).

Where the rule stops

Fowler's PresentationDomainDataLayering (26 August 2015) is the corrective everyone needs after their first clean-architecture project:

"once any of these layers gets too big you should split your top level into domain oriented modules which are internally layered" — Fowler

Layers are a within-module concern. If src/ starts with controllers/, services/, repositories/, you have taken the smallest useful idea in these papers and made it your primary axis of organisation. Part 3 of this series is about fixing exactly that.

Fowler adds an organisational point in the same article that is worth keeping on a sticky note: "Developers don't have to be full-stack but teams should be."

Sources