Making Code
Back to blog
Architecture4 min read

Why Your NestJS Service Becomes a Mess (and How Hexagonal Architecture Fixes It)

Controllers that know too much, entities full of ORM decorators, and tests that need a database. A practical guide to ports and adapters in NestJS.

Why Your NestJS Service Becomes a Mess (and How Hexagonal Architecture Fixes It)

You start a NestJS project with good intentions. Six months later, users.service.ts imports TypeORM, sends emails, hashes passwords, and publishes Kafka events. Changing the database feels like surgery.

That is not a team problem. It is an architecture problem — and hexagonal architecture (ports and adapters) is one of the most effective fixes for backend teams that outgrow the classic controller → service → entity stack.

This article walks through the model used in the NestJS Enterprise Starter, with enough detail that you can apply it in your own codebase this week.

The real cost of a "simple" service layer

When business logic, HTTP, and persistence live in the same class:

  • Tests need Postgres — slow feedback, skipped tests, regressions in production.
  • Framework lock-in — TypeORM decorators become your "domain model."
  • Controllers grow fat — validation, hashing, and side effects end up in @Post() handlers.

Hexagonal architecture draws a line: the center is your business rules; everything else is a plugin you can replace.

The picture that actually helps

Imagine your app as a hexagon:

  • Driving adapters push work in: REST controllers, GraphQL, CLI, queue consumers.
  • Driven adapters are called by the core: PostgreSQL, Redis, Stripe, SendGrid.
  • Ports are TypeScript interfaces the core defines — "I need to save a user," not "I use TypeORM."
HTTP  →  Application (use cases)  →  Domain

TypeORM repo implements UserRepositoryPort

The rule is simple: dependencies point inward. core/ never imports @nestjs/common or typeorm.

A folder structure you can adopt tomorrow

src/
├── core/
│   ├── domain/
│   └── ports/output/persistence/
├── application/
│   └── commands/user/
├── adapters/
│   ├── primary/http/
│   └── secondary/persistence/typeorm/
└── modules/user/

NestJS modules become wiring only: they bind UserRepositoryPortUserRepository. Handlers depend on the port; the module chooses the adapter.

Ports: contracts, not implementations

The domain declares what it needs:

export abstract class UserRepositoryPort {
  abstract findByEmail(email: string): Promise<User | null>;
  abstract create(payload: CreateUserPayload): Promise<User>;
}

No @Injectable(), no Repository<UserOrmEntity>. The handler asks for a capability; NestJS injects the implementation at runtime.

Adapters: where frameworks live

The TypeORM entity stays in adapters/secondary:

@Entity('users')
export class UserOrmEntity {
  @PrimaryGeneratedColumn('uuid')
  id: string;
 
  @Column({ unique: true })
  email: string;
  // ...
}

A mapper translates ORM → domain:

static toDomain(orm: UserOrmEntity): User {
  return { id: orm.id, email: orm.email, name: orm.name, ... };
}

Handlers never see UserOrmEntity. When you migrate to Prisma, you rewrite one adapter — not forty use cases.

Controllers should translate, not decide

Bad:

@Post()
async create(@Body() dto: CreateUserDto) {
  const hash = await bcrypt.hash(dto.password, 10);
  return this.repo.save({ ...dto, password: hash });
}

Good:

@Post()
create(@Body() dto: CreateUserDto) {
  return this.commandBus.execute(new CreateUserCommand(dto));
}

Password rules, duplicate email checks, and welcome emails belong in a command handler in the application layer.

What you gain in practice

Benefit What it feels like day to day
Fast unit tests Handlers tested with jest.fn() mocks — no Docker
Swappable infra New queue or DB = new adapter + provider
Onboarding Juniors know where code belongs
Reuse Same handler from HTTP, cron, or BullMQ worker

Mistakes I see in every audit

  1. ORM entities in core/ — move them to adapters; map to plain types.
  2. Injecting concrete UserRepository in handlers — inject UserRepositoryPort.
  3. "Just this once" logic in the controller — it never stays once.

Where to go next

Hexagonal layout pairs naturally with CQRS (separate read and write paths) and ports for queues (BullMQ behind IQueueService). In this series, we cover those patterns next — same repo, same dependency direction.

If you are building a NestJS API that must survive more than one hiring cycle, start by extracting one module (Users) into ports and adapters. You will feel the difference in the first week of tests.

Get new posts by email

No spam — just a note when I publish something new on backend, cloud, and architecture.

One email when a post goes live. Unsubscribe anytime.

Related articles