back
NestJS Clean Code Using Hexagonal Architecture
Apr 7, 2022·9 min read

NestJS Clean Code Using Hexagonal Architecture


Imagine you’ve built a ticket management service backed by an in-memory store. It works. Six months later, a new requirement arrives: persist tickets in Postgres. A year after that, a different client needs DynamoDB. If your business logic is tangled up with your storage layer, each of those changes is a rewrite risk. If it isn’t, swapping the storage adapter is a single line change in a module file.

That’s the practical case for Hexagonal Architecture — not a pattern to follow for its own sake, but a structural decision that keeps your core business logic independent of everything around it: databases, HTTP frameworks, message queues, and any other infrastructure that changes more often than the rules of your domain.

This article walks through implementing it in NestJS using a ticket management service as the example.

What is Hexagonal Architecture?

Hexagonal Architecture (also called Ports and Adapters) divides your application into two zones:

The Core Domain contains your business logic and the rules that make your application useful. It has no imports from NestJS, no database clients, no HTTP libraries. It only knows about itself.

Adapters are the implementations that connect your domain to the outside world. A REST controller is an adapter. A TypeORM repository is an adapter. An in-memory store used in tests is an adapter.

The boundary between them is defined by ports — interfaces the domain declares but does not implement:

NestJS’s dependency injection system is well-suited to this pattern. You declare what the domain needs via an interface, register the concrete implementation in a module, and the framework handles wiring at runtime. Swapping implementations — for a different database, or for a test double — is a module-level concern, not a domain concern.

Project Structure

Start with a new NestJS module:

nest generate module ticket

Then create the directory layout that maps to the architecture:

cd src/ticket && mkdir -p adapters/db adapters/api domain/model domain/ports

The resulting structure:

src/ticket/
├── adapters/
│   ├── api/
│   │   ├── ticket.command.ts
│   │   └── ticket.controller.ts
│   └── db/
│       ├── ticket-in-memory.repository.ts
│       └── ticket-postgres.repository.ts
├── domain/
│   ├── model/
│   │   └── ticket.model.ts
│   └── ports/
│       ├── ticket.repository.ts   ← output port (interface)
│       └── ticket.service.ts      ← input port (service)
└── ticket.module.ts

Everything inside domain/ must stay free of infrastructure imports. That constraint is the whole point.

The Domain Model

Create the Ticket entity inside domain/model/ticket.model.ts:

import { randomUUID } from 'crypto';

export enum TicketStatus {
  OPEN = 'OPEN',
  IN_PROGRESS = 'IN_PROGRESS',
  CLOSED = 'CLOSED',
}

export class Ticket {
  private id: string;
  description: string;
  status: TicketStatus;
  createdAt: Date;
  updatedAt: Date;
  priority: number;

  constructor(description: string, priority: number) {
    this.id = randomUUID();
    this.description = description;
    this.status = TicketStatus.OPEN;
    this.createdAt = new Date();
    this.updatedAt = new Date();
    this.priority = priority;
  }

  isClosed(): boolean {
    return this.status === TicketStatus.CLOSED;
  }
}

This class is pure TypeScript. No decorators, no framework imports, no database annotations. It can be instantiated and tested anywhere.

The Output Port

The domain needs to store and retrieve tickets, but it must not know how. Define the contract as an interface in domain/ports/ticket.repository.ts:

import { Ticket } from '../model/ticket.model';

export interface TicketRepository {
  create(ticket: Ticket): Ticket;
  findAll(): Ticket[];
}

export const TicketRepository = Symbol('TicketRepository');

The Symbol on the last line is how NestJS identifies this token in the DI container at runtime, since TypeScript interfaces are erased at compile time. You export both the interface (used for typing) and the symbol (used for injection) under the same name. NestJS resolves the correct implementation at startup based on your module configuration.

The Input Port

The service in domain/ports/ticket.service.ts implements the business rules. It receives a TicketRepository via dependency injection but only knows about the interface — never the concrete class:

import { Inject, Injectable } from '@nestjs/common';
import { Ticket } from '../model/ticket.model';
import { TicketRepository } from './ticket.repository';

@Injectable()
export class TicketService {
  constructor(
    @Inject(TicketRepository)
    private readonly ticketRepository: TicketRepository,
  ) {}

  create(description: string, priority: number): Ticket {
    if (this.findActiveTickets().length >= 3) {
      throw new Error('Maximum of 3 active tickets allowed');
    }
    const ticket = new Ticket(description, priority);
    this.ticketRepository.create(ticket);
    return ticket;
  }

  findAll(): Ticket[] {
    return this.ticketRepository.findAll();
  }

  findActiveTickets(): Ticket[] {
    return this.ticketRepository.findAll().filter((t) => !t.isClosed());
  }
}

Notice !t.isClosed() — the method is called, not referenced. A common mistake is writing !t.isClosed without the parentheses, which always evaluates to true since you’re checking whether the method reference is truthy, not its return value. The business rule silently breaks.

The @Inject(TicketRepository) decorator tells NestJS to resolve the DI token — the Symbol — and inject whatever implementation is registered for it in the current module.

The API Adapter

The controller in adapters/api/ticket.controller.ts is an inbound adapter. It translates HTTP requests into domain calls:

import { Body, Controller, Get, Post } from '@nestjs/common';
import { TicketService } from '../../domain/ports/ticket.service';
import { TicketCommand } from './ticket.command';

@Controller({ path: 'tickets', version: ['1'] })
export class TicketController {
  constructor(private readonly ticketService: TicketService) {}

  @Get()
  findAll() {
    return this.ticketService.findAll();
  }

  @Post()
  create(@Body() command: TicketCommand) {
    return this.ticketService.create(command.description, command.priority);
  }
}

And the request DTO with validation:

import { IsNotEmpty, IsInt, Min, Max } from 'class-validator';

export class TicketCommand {
  @IsNotEmpty()
  description: string;

  @IsInt()
  @Min(1)
  @Max(5)
  priority: number;
}

The controller knows nothing about how tickets are stored. It calls the service, gets back a domain object, and returns it. If you later replace the REST controller with a gRPC adapter, the domain is untouched.

The DB Adapters

Create the in-memory implementation in adapters/db/ticket-in-memory.repository.ts:

import { Injectable } from '@nestjs/common';
import { Ticket } from '../../domain/model/ticket.model';
import { TicketRepository } from '../../domain/ports/ticket.repository';

@Injectable()
export class TicketInMemoryRepository implements TicketRepository {
  private readonly tickets: Ticket[] = [];

  create(ticket: Ticket): Ticket {
    this.tickets.push(ticket);
    return ticket;
  }

  findAll(): Ticket[] {
    return [...this.tickets];
  }
}

When you’re ready to add Postgres, create a second adapter in adapters/db/ticket-postgres.repository.ts that implements the same interface:

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Ticket } from '../../domain/model/ticket.model';
import { TicketRepository } from '../../domain/ports/ticket.repository';
import { TicketEntity } from './ticket.entity';

@Injectable()
export class TicketPostgresRepository implements TicketRepository {
  constructor(
    @InjectRepository(TicketEntity)
    private readonly repo: Repository<TicketEntity>,
  ) {}

  create(ticket: Ticket): Ticket {
    // map domain model → entity, persist, return domain model
    return ticket;
  }

  findAll(): Ticket[] {
    // query entities, map → domain models, return
    return [];
  }
}

The domain has no awareness that either of these classes exists. The seam between domain and infrastructure is clean.

Wiring it Together

In ticket.module.ts, register which implementation to use for the TicketRepository token:

import { Module } from '@nestjs/common';
import { TicketController } from './adapters/api/ticket.controller';
import { TicketInMemoryRepository } from './adapters/db/ticket-in-memory.repository';
import { TicketRepository } from './domain/ports/ticket.repository';
import { TicketService } from './domain/ports/ticket.service';

@Module({
  controllers: [TicketController],
  providers: [
    TicketService,
    {
      provide: TicketRepository,
      useClass: TicketInMemoryRepository,
    },
  ],
})
export class TicketModule {}

To switch to Postgres: change useClass: TicketInMemoryRepository to useClass: TicketPostgresRepository. One line. The domain, the controller, and the tests do not change.

You can also drive this with an environment variable:

{
  provide: TicketRepository,
  useClass:
    process.env.DB === 'postgres'
      ? TicketPostgresRepository
      : TicketInMemoryRepository,
}

Testing the Domain in Isolation

This is the payoff. Because TicketService depends only on the TicketRepository interface, you can test it without starting NestJS, without a database, and without any HTTP layer. Pass a plain stub that implements the interface:

import { TicketService } from './ticket.service';
import { TicketRepository } from './ticket.repository';
import { Ticket, TicketStatus } from '../model/ticket.model';

class StubTicketRepository implements TicketRepository {
  private tickets: Ticket[] = [];

  create(ticket: Ticket): Ticket {
    this.tickets.push(ticket);
    return ticket;
  }

  findAll(): Ticket[] {
    return this.tickets;
  }

  // helper for tests — not part of the interface
  seed(tickets: Ticket[]) {
    this.tickets = tickets;
  }
}

describe('TicketService', () => {
  let service: TicketService;
  let repository: StubTicketRepository;

  beforeEach(() => {
    repository = new StubTicketRepository();
    service = new TicketService(repository);
  });

  it('creates a ticket when under the limit', () => {
    const ticket = service.create('Login page is broken', 1);
    expect(ticket).toBeDefined();
    expect(service.findAll()).toHaveLength(1);
  });

  it('throws when 3 active tickets already exist', () => {
    service.create('Issue one', 1);
    service.create('Issue two', 2);
    service.create('Issue three', 3);

    expect(() => service.create('Issue four', 1)).toThrow(
      'Maximum of 3 active tickets allowed',
    );
  });

  it('does not count closed tickets toward the limit', () => {
    const t1 = service.create('Issue one', 1);
    const t2 = service.create('Issue two', 2);
    t1.status = TicketStatus.CLOSED;

    // Only one active ticket remains, so a new one should be allowed
    expect(() => service.create('Issue three', 1)).not.toThrow();
  });
});

No @nestjs/testing, no TestingModule, no database setup. The test runs in milliseconds and targets the exact business rules. The architecture makes this trivially easy — because the domain has no infrastructure dependencies, the test has none either.

Conclusion

Hexagonal Architecture earns its complexity through one specific benefit: the boundary between your domain and your infrastructure becomes explicit, enforced, and testable. The domain does not know whether it is talking to Postgres, DynamoDB, or an array in memory. The tests do not know either — and that is precisely the point.

In NestJS, the DI system handles the wiring. The pattern itself requires only discipline about what imports what. Keep infrastructure out of domain/, keep business logic out of adapters/, and the architecture enforces itself.

Source code: github.com/ridakaddir/nest-hexagonal