Skip to content
See all notes
architecturenestjsdddtypescript

Hexagonal architecture in NestJS without losing your mind

Hexagonal architecture is always explained with concentric circles and almost never with code. Here is how I apply it in NestJS, which folders I create, the one rule that actually holds it together, and when it stops being worth it.

· 8 min read

Hexagonal architecture is almost always explained with the same diagram of concentric circles, and almost never with code. By the time you sit down to write it, your questions are far more boring: which folders do I create, where does this file go, and why can my service not import the repository?

Here is how I apply it in NestJS.

The problem it solves#

A typical NestJS module starts like this:

@Injectable()
export class ExpenseService {
  constructor(private readonly prisma: PrismaService) {}

  async approve(id: string) {
    const expense = await this.prisma.expense.findUnique({ where: { id } });
    if (expense.status !== 'PENDING') throw new BadRequestException();

    return this.prisma.expense.update({
      where: { id },
      data: { status: 'APPROVED' },
    });
  }
}

It works. And in three months it has four hundred lines, six injected dependencies, and one business rule — "only pending expenses can be approved" — scattered across this service, a guard, and the frontend.

The problem is not Prisma. The problem is that the business rule lives glued to how the data is stored. When you change databases, or want to test the rule without spinning one up, you will find out what that closeness cost.

Three layers, as folders#

In every module I create exactly this:

modules/treasury/
├── domain/           entities, value objects, rules, errors
├── application/      use cases and ports (interfaces)
└── infrastructure/   controllers, repositories, listeners

One single rule holds the whole thing together:

domain/ imports nothing. application/ imports domain/. infrastructure/ imports both. Never the other way round.

Everything else follows from that sentence.

domain/ knows nothing about the world#

No NestJS decorators here, no Prisma, no @Injectable. Just TypeScript. The rule from earlier lives in the entity:

export class Expense extends AggregateRoot {
  private constructor(
    private readonly props: ExpenseProps,
    id?: UniqueEntityId,
  ) {
    super(id);
  }

  approve(): Result<void> {
    if (this.props.status !== ExpenseStatus.PENDING) {
      return Result.fail({
        code: TREASURY_ERROR_CODES.EXPENSE_NOT_PENDING,
        message: 'Only a pending expense can be approved',
      });
    }

    this.props.status = ExpenseStatus.APPROVED;
    return Result.ok();
  }
}

Notice it does not throw: it returns a Result. A broken business rule is not a program error, it is a possible outcome — and treating it as an exception forces you to wrap half the codebase in try/catch just to tell "the expense was not pending" apart from "the database went down".

application/ orchestrates and declares what it needs#

The use case does not know where the data comes from. It only declares a port:

export interface ExpenseRepository {
  findById(id: string): Promise<Expense | null>;
  save(expense: Expense): Promise<void>;
}

export const EXPENSE_REPOSITORY = Symbol('ExpenseRepository');

And consumes it:

@Injectable()
export class ApproveExpenseUseCase implements IUseCase<Input, void> {
  constructor(
    @Inject(EXPENSE_REPOSITORY)
    private readonly expenses: ExpenseRepository,
  ) {}

  async execute({ expenseId }: Input): Promise<Result<void>> {
    const expense = await this.expenses.findById(expenseId);
    if (!expense) {
      return Result.fail({ code: TREASURY_ERROR_CODES.EXPENSE_NOT_FOUND });
    }

    const approved = expense.approve();
    if (approved.isFailure) return approved;

    await this.expenses.save(expense);
    return Result.ok();
  }
}

This use case is tested without a database, without NestJS, and without HTTP. You hand it a test double for the repository and assert the rule. The tests run in milliseconds.

infrastructure/ connects to reality#

This is where Prisma, controllers, and decorators finally show up:

@Injectable()
export class PrismaExpenseRepository implements ExpenseRepository {
  constructor(private readonly prisma: PrismaService) {}

  async findById(id: string): Promise<Expense | null> {
    const row = await this.prisma.expense.findUnique({ where: { id } });
    return row ? ExpenseMapper.toDomain(row) : null;
  }

  async save(expense: Expense): Promise<void> {
    const data = ExpenseMapper.toPersistence(expense);
    await this.prisma.expense.upsert({
      where: { id: data.id },
      create: data,
      update: data,
    });
  }
}

And the module wires the port to its implementation:

@Module({
  providers: [
    ApproveExpenseUseCase,
    { provide: EXPENSE_REPOSITORY, useClass: PrismaExpenseRepository },
  ],
})
export class TreasuryModule {}

The part nobody mentions: the rule erodes on its own#

All of the above is the easy part. The hard part is that two months from now, someone in a hurry — you, on a Tuesday — will import the Prisma repository from a use case. It will work. Nobody will catch it in review. And the boundary will have quietly stopped existing.

A team agreement does not survive a Tuesday in a hurry. So the rule gets automated:

// .dependency-cruiser.cjs
{
  name: 'domain-must-not-import-application-or-infra',
  severity: 'error',
  from: { path: 'src/modules/[^/]+/domain' },
  to: { path: 'src/modules/[^/]+/(application|infrastructure)' },
}

Now crossing the boundary breaks the build. The architecture stops depending on everyone remembering, which is the only thing that keeps it alive at the six month mark.

When it is NOT worth it#

This has a real cost, and it is worth saying out loud:

SituationWorth it?
CRUD with no business rulesNo. You are writing three layers for a SELECT
Prototype to validate an ideaNo. You are going to throw the code away
A domain with rules and statesYes
Several teams on the same codebaseYes, considerably
Persistence that might changeYes

A catalogue module that only lists and edits records does not need ports: mappers and interfaces just add files to read. Hexagonal pays off when there are rules to protect, not when there are tables to expose.

I apply it per module, not per project. In the same system there are modules with all three layers and modules that are a controller and a service, and that is fine: architecture is chosen for the problem in front of you, not for aesthetic consistency.

What took me longest to understand#

That the goal is not to swap databases. Almost nobody swaps databases.

The goal is being able to read a business rule without reading how it is stored, and being able to test it without spinning anything up. That pays out every week, in every code read and every test that takes milliseconds instead of seconds. Swapping persistence is a pleasant side effect you will probably never use.

Got a system with this kind of problem?

Tell me what you are working on and I will tell you how I would approach it.

Let's talk about your system