Hexagonal architecture in NestJS: structure, rules, and when to skip it
How ports and adapters map onto a NestJS project: module-based folders, the dependency rule that matters, wiring with Nest DI, and when to skip it.
A typical NestJS project starts with the structure the CLI generates: a module with a controller, a service, and an entity. It works well for a long time. Then the service imports the ORM directly, the entity doubles as the API response, business rules end up spread over controllers and queries, and testing a rule means starting a database.
Hexagonal architecture, also called ports and adapters, is a way to prevent that drift. Alistair Cockburn described it in 2005. This article covers how it maps onto NestJS and, as importantly, when it is not worth the effort. It deliberately stays at the level of concepts and structure instead of a full example.
The idea in three parts
- The core contains the business rules: the domain model (entities, value objects) and the use cases that coordinate them. It knows nothing about HTTP, SQL, queues, or Nest.
- Ports are interfaces that the core owns. An inbound port describes what the outside world can ask the core to do (
PlaceOrderUseCase). An outbound port describes what the core needs from the outside (OrderRepository,PaymentGateway). Outbound ports are usually declared in the domain, because they are phrased in its terms; inbound ports belong to the application layer, which orchestrates the use cases. Some teams put all ports in the application layer instead, which is equally valid as long as the core owns them and not the adapters. - Adapters implement or call ports. Anything external (database, payment provider, mail service) is reached only through an adapter. A driving adapter (an HTTP controller, a CLI command, a queue consumer) calls an inbound port. A driven adapter (a TypeORM repository, a payment API client) implements an outbound port.
The rule that holds this together is about dependency direction: adapters depend on the core, the core never depends on adapters. The core defines what it needs as an interface, and an adapter fulfils it. That is why you can swap the database or test a use case without one.
A folder structure: modules plus an AOP folder
Group by module first (one folder per business capability under modules/), then by role inside the module. Cross-cutting concerns such as logging live outside the modules, in an aop/ folder:
src/
aop/
decorators/
filters/
domain-exception.filter.ts
guards/
interceptors/
logging.interceptor.ts
modules/
orders/
domain/
order.ts
order-line.ts
order-status.ts
ports/
order.repository.ts
payment.gateway.ts
application/
ports/
place-order.use-case.ts
place-order.service.ts
adapters/
in/
http/
orders.controller.ts
place-order.request.ts
out/
persistence/
order.entity.ts
order.mapper.ts
typeorm-order.repository.ts
payment/
http-payment.gateway.ts
orders.module.ts
app.module.ts
main.ts
What goes where:
domain/holds plain TypeScript classes with the business rules (“an order cannot be placed with zero lines”), plus the outbound ports the domain needs: the repository interface and the interface to the payment service. They are interfaces only, written in the domain’s own language. No decorators from Nest or the ORM.application/holds the use cases and the inbound ports (what the outside may ask for). A use case loads what it needs through the outbound ports, calls the domain, and saves the result through them.adapters/in/is where request validation and DTOs live. A controller translates HTTP into a use case call and the result back into a response.adapters/out/holds the implementations of the outbound ports: everything that actually touches a database or an external service, including the ORM entity.http-payment.gateway.tsis the adapter that calls the payment provider;payment.gateway.tsin the domain is only the interface it fulfils. The mapper converts between the ORM entity and the domain object.orders.module.tsis the only place that knows which adapter implements which port.aop/holds cross-cutting concerns that apply to many modules: logging interceptors, exception filters that map domain errors to HTTP responses, guards, and shared decorators. It must not contain business rules, and it must not import frommodules/. Modules use it through Nest’s mechanisms (global registration or decorators), never the other way round.
Modules stay separate from each other as well. If shipping needs something from orders, it goes through an exported port or use case of that module, not through its adapters or domain classes.
Wiring it with Nest’s dependency injection
TypeScript interfaces do not exist at runtime, so Nest cannot use them as injection tokens. Use a Symbol or a string constant next to the interface, and bind it in the module:
// domain/ports/order.repository.ts
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | undefined>;
}
export const ORDER_REPOSITORY = Symbol("ORDER_REPOSITORY");
// orders.module.ts
@Module({
controllers: [OrdersController],
providers: [
PlaceOrderService,
{ provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
],
})
export class OrdersModule {}
The use case asks for the token with @Inject(ORDER_REPOSITORY). In a test you provide an in-memory implementation under the same token, and the use case never knows the difference. An abstract class works as a token too and saves the extra constant, at the price of the port being a class.
Pragmatic point: the use case service can carry Nest’s @Injectable() decorator. Keeping decorators out of the domain matters; keeping them out of the application layer rarely pays for itself.
Keep the rule enforced
A dependency rule that exists only in a document will be broken within a month. Check it in CI. Fallow can do this: its architecture boundaries describe zones (sets of files) and which zones may import from which, and fallow architecture reports every import that crosses a forbidden line. A configuration for the structure above, in .fallowrc.json:
{
"boundaries": {
"zones": [
{ "name": "domain", "patterns": ["src/modules/*/domain/**"] },
{ "name": "application", "patterns": ["src/modules/*/application/**"] },
{ "name": "adapters", "patterns": ["src/modules/*/adapters/**"] },
{ "name": "aop", "patterns": ["src/aop/**"] }
],
"rules": [
{ "from": "domain", "allow": [] },
{ "from": "application", "allow": ["domain"] },
{ "from": "adapters", "allow": ["application", "domain"] },
{ "from": "aop", "allow": [] }
]
}
}
An empty allow list isolates a zone. Here the domain imports nothing outside itself, the application layer may use the domain, and adapters may use both. aop/ cannot import any module code. The *.module.ts files and app.module.ts match no zone, so they stay unrestricted, which is right because they are where everything is wired together. Run fallow list --boundaries to see which files each zone picked up before you rely on it.
Two limits to know about. The rules work on folders, so they do not say “the application layer must not import @nestjs/typeorm”; that still needs a lint rule or review. And this configuration does not stop one module from importing another module’s adapters; that is worth a rule of its own once you have more than one or two modules. On an existing codebase, fallow audit reports only the findings that a change introduces, so you can switch it on without fixing the whole backlog first. Fallow also reports unused exports and circular dependencies, which helps to spot ports nobody uses any more.
Write the rule once and it will pay for itself the first time a tired colleague takes a shortcut.
What you gain
- Fast tests for the logic that matters. Use cases run against in-memory ports in milliseconds, without a database or a running Nest app.
- Replaceable infrastructure. Changing the payment provider means writing one adapter. The use case does not change.
- A visible place for each kind of code. New developers can find where validation, business rules, and queries belong.
What it costs
- More files and more mapping. A simple field now exists in the request, the domain object, the ORM entity, and the response. That is boilerplate you will write many times.
- Indirection when reading. To see what happens on
POST /orders, you follow the controller, an interface, and a module binding. - The risk of ports that mirror the ORM. An
OrderRepositorywithfindByQuery(query: SelectQueryBuilder)is an adapter in disguise. A port should speak the language of the domain:findOpenOrdersOf(customerId).
When to skip it
For a CRUD service that mostly stores and returns data, hexagonal structure adds ceremony without protecting anything, because there is no rule worth isolating. A single module with a controller, a service, and a repository is the right size.
A practical path for many teams: start simple, and extract a port the first time one of these appears: a second implementation (another provider, a fake for tests), a business rule that is hard to test, or an external service you do not trust to stay the same. Extracting one port later is a small refactoring. Building every port up front for requirements that never come is not.
If the choice is not obvious, write it down: this is exactly the kind of decision an architecture decision record is for.