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.

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 UserRepositoryPort → UserRepository. 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
- ORM entities in
core/— move them to adapters; map to plain types. - Injecting concrete
UserRepositoryin handlers — injectUserRepositoryPort. - "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

How to Build a Multi-Tenant SaaS Application in NestJS Without Duplicating Your Code
If onboarding a new customer requires deploying a new application or duplicating an entire database, your SaaS architecture probably isn't ready to scale. Learn how to implement multi-tenancy in NestJS in a clean and maintainable way.

Your API Doesn't Need More Services, It Needs Events
If every new feature forces you to modify five different services, you probably have a coupling problem. Learn how Event-Driven Architecture helps decouple modules and scale NestJS applications.

CQRS in NestJS: Stop Mixing Reads and Writes in the Same Service
When your UserService handles POST and GET, optimizations on one side break the other. Learn commands, queries, and handlers with @nestjs/cqrs.