Hexagonal Architecture (also called ports and adapters) is a way to organize code so that the business logic does not depend on the framework, the database, and external services. But it is not free: extra layers, mappers, and settings appear. Let us look at when it is justified and when it is better to keep things simpler.
What Hexagonal Architecture is
The idea is simple: split the code into three zones.
- Core — business logic only. No imports from NestJS, TypeORM, or axios. Pure TypeScript classes.
- Adapters/in — how requests get into the system: HTTP controllers, Kafka consumers, schedulers.
- Adapters/out — how the system reaches outside: writing to a database, calling an external API, sending events.
The link between the core and the adapters goes only through ports — interfaces declared in the core. The adapter implements the interface, the core calls it. The core never knows what exactly stands behind the port.
In Java, the boundaries between layers are held by separate gradle modules — the compiler will not let you import from a foreign module. In Node there is no such thing, so in NestJS projects the role of the boundaries is played by dependency-cruiser: the tool checks the import graphs and does not let the build pass in CI if someone broke the rules.
Why NestJS decorators are not allowed in the core
When you put @Injectable() or @Inject() on a class in core/, that class becomes dependent on NestJS. Now, in a test, you cannot create it simply with new Order() — you have to spin up a TestingModule.
That is why handlers and domain services in core/ are plain TypeScript classes, without decorators. Wiring into the NestJS DI container is done through useFactory providers in app/ — once, at the application's assembly point.
Signs of "it's time"
These are the signals under which Hexagonal starts to deliver a real payoff.
1. The service works with several external systems. For example: PostgreSQL + a payment gateway + Kafka + SMS. Each system has its own data schema, its own errors, its own timeouts. Without clear boundaries, all of this gathers in a single handler and becomes hard to test.
// core/order/port/out/payment-port.ts
export const PAYMENT_PORT = Symbol('PaymentPort');
export interface PaymentPort {
register(cmd: RegisterPayment): Promise<RegisterResult>;
cancel(paymentId: PaymentId): Promise<void>;
}
// adapters/out/sber/sber-payment.adapter.ts knows about SberRegisterRequest and axios;
// the core knows about neither
2. Business logic accumulates in the handler. If you see if/else with business rules right in the handler, that is a signal that the logic should be moved into the aggregate. An aggregate in core/ is a plain class without NestJS:
// core/order/aggregate/order.aggregate.ts
export class Order {
confirm(now: Date): OrderConfirmed {
if (this.status !== OrderStatus.PENDING) throw new OrderAlreadyConfirmedError(this.id);
if (this.items.length === 0) throw new EmptyOrderError(this.id);
this.status = OrderStatus.CONFIRMED;
this.confirmedAt = now;
return new OrderConfirmed(this.id, now);
}
}
3. Several types of input. REST for users + REST for administrators + a Kafka consumer. Each input has its own security model and its own Guards. Without separation they start to mix, and the admin Guard accidentally covers the user endpoints.
4. Tests are forced to spin up a NestJS context. If a unit test of the business logic creates a TestingModule with providers, that is a sign that the domain classes depend on the framework. In Hexagonal an aggregate test looks like this:
// neither createTestingModule nor @nestjs/*
describe('Order.confirm', () => {
it('throws EmptyOrderError for an empty order', () => {
const order = Order.create(orderId, customerId, []);
expect(() => order.confirm(new Date())).toThrow(EmptyOrderError);
});
});
It runs in milliseconds and requires no DI container.
5. A team of three or more developers. When several people edit one service, architectural boundaries become a social necessity. dependency-cruiser in CI catches a violation automatically — for example, when someone added @Injectable in core/ and code review missed it:
// .dependency-cruiser.cjs
{ name: 'core-pure', severity: 'error',
from: { path: '^src/core' },
to: { path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator))' } }
If at least three of the five points hold, moving to Hexagonal is justified.
Signs of "too early"
One service with one database. If everything external is only PostgreSQL via TypeORM, the full hex layout is not needed. A CustomerRepository interface in core/ and its implementation in adapters/out/persistence/ are enough — that is a basic boundary that does not require a dependency-cruiser config.
One or two developers, a small service. Conventions hold verbally. Adding useFactory wiring for three handlers instead of @Injectable() for the sake of "what if someone adds TypeORM to the core" is extra work on a small team.
The model is still changing. If the business changes the rules every two weeks and the model has not settled, the hex structure slows you down: any change to Product requires editing the persistence mapper, the http-adapter mapper, and the DTO in OpenAPI. First a stable model, then Hexagonal.
There is no real business logic. If the aggregate is just { id, name, email } with getters, it is not worth building a hex wrapper for it. An empty domain model in a hexagonal structure is the most expensive kind of CRUD.
Partial Hexagonal — a common mistake
A common scenario: core/ is carved out, but the inbound adapters mix NestJS controllers with business logic. Or adapters/out/persistence/ exists, but calls to an external API sit right in the handler through an injected axios.
Problems with this approach:
- dependency-cruiser works at half strength. The tool catches violations only where there are rules. If part of the service is outside the contract, the boundaries are partially open and there is no confidence.
- Harder to read. The developer constantly checks: "is this Hexagonal already, or not yet?" Such a mix is harder to understand than a clean monolith.
- The refactoring is put off. "We'll finish it later" usually does not happen — the work takes weeks and does not fit into a sprint.
The rule: either full Hexagonal — the layout core/ + adapters/in/ + adapters/out/ + app/, dependency-cruiser in CI as a mandatory check, Symbol tokens for all ports, useFactory wiring — or nothing. An intermediate state is acceptable only as a short migration period with an explicit deadline.
Cargo cult: one template architecture for everything
Another typical mistake is to force all the team's services under one template regardless of their complexity. A three-endpoint service in the full hex layout with Symbol tokens, useFactory for every handler, and a dependency-cruiser config is architecture for architecture's sake.
The decision is made per service. Within one team it is perfectly normal to have a simple catalog service and a complex orders service with billing — with different architectures suited to their actual complexity.
In short
- Hexagonal Architecture splits the code into a core (business logic), inbound adapters (HTTP, Kafka), and outbound adapters (database, external APIs). The link is only through port interfaces.
- In NestJS the boundaries are held by dependency-cruiser in CI — it replaces the compile-time isolation that Node lacks.
- Handlers and domain classes in
core/are plain TypeScript, without@Injectable. Wiring into DI is throughuseFactoryinapp/. - It is worth moving if: several external systems, complex business logic with invariants, several types of input, tests requiring a Nest context, a team of 3+ people.
- It is not worth moving if: one database, a small team, the model is still changing, there is no real business logic.
- Partial Hexagonal is more dangerous than a monolith — it creates an illusion of control without real guarantees.
- Architecture is chosen to fit the complexity of a specific service, not applied as one template across the whole team.
What to read next
- Module structure — what exactly we build once we decide to move.
- Core layer — what goes into
core/and why@Injectableis forbidden there. - Ports — Symbol tokens, interfaces, domain types.
- Inbound adapters — controller, mapper, admin isolation.
- Outbound adapters — per-system folders, port implementation, mapping.
- Composition root —
AppModule,main.ts,useFactory. - Architecture tests — dependency-cruiser in CI.