In Java the compiler guards the Hexagonal architecture: if core/ accidentally references Spring, the build fails. In Node.js there is no such protection — TypeScript does not forbid importing anything from anywhere. A folder structure without an extra tool is just a convention that is easy to break.
This article explains: exactly how to lay out the files, what goes where, and how to set up an automatic check of the boundaries between layers with dependency-cruiser.
The four zones of a project
In a Hexagonal NestJS project all the code splits into four zones inside src/:
src/
core/ # business logic — knows nothing about NestJS, TypeORM, axios
adapters/ # input (HTTP, Kafka) and output (DB, external APIs)
app/ # the assembly point — puts everything together
The dependency arrow is strictly one-way:
app → adapters → core
core/ knows nothing about adapters/ or app/. The adapters/ do not know about each other. app/ knows about all of them — it is the one that ties the parts together.
core/ — business logic without infrastructure
Here lives everything that belongs to the problem domain: aggregates, value objects, domain events, port interfaces, commands, and handlers.
core/
order/
aggregate/ # Order, OrderItem
value-object/ # OrderId, Money, CustomerId
event/ # OrderCreatedEvent, OrderConfirmedEvent
port/
out/ # OrderRepository, PaymentPort — interfaces + Symbol tokens
usecases/ # CreateOrderCommand, CreateOrderHandler
product/
aggregate/
port/out/
usecases/
shared/
dispatcher.ts # the single entry point into the core
value-object/ # Money, Clock, TransactionRunner
The main constraint of core/: @nestjs/*, typeorm, class-validator, axios do not appear here. An aggregate class is just a TypeScript class with no third-party dependencies:
// core/order/aggregate/order.ts
import { randomUUID } from 'node:crypto';
import { OrderId } from '../value-object/order-id';
import { Money } from '../../shared/value-object/money';
import { OrderStatus } from '../value-object/order-status';
import { OrderCreatedEvent } from '../event/order-created.event';
export class Order {
private constructor(
readonly id: OrderId,
private _totalAmount: Money,
private _status: OrderStatus,
private readonly _events: unknown[],
) {}
static create(customerId: CustomerId, items: OrderItem[], clock: Clock): Order {
const id = new OrderId(randomUUID());
const total = items.reduce((acc, i) => acc.add(i.price), Money.zero());
const order = new Order(id, total, OrderStatus.PENDING, []);
order._events.push(new OrderCreatedEvent(id, customerId, total, clock.now()));
return order;
}
confirm(): void {
if (this._status !== OrderStatus.PENDING) {
throw new OrderAlreadyConfirmedError(this.id);
}
this._status = OrderStatus.CONFIRMED;
}
pullEvents(): unknown[] { return this._events.splice(0); }
}
Not a single import from the framework. Code like this can be tested without spinning up NestJS.
Handlers — plain classes without @Injectable
A typical mistake is to put @Injectable() on a handler in core/. Then the handler becomes dependent on @nestjs/common, which breaks the isolation.
Handlers in core/ are plain TypeScript classes. NestJS knows nothing about them. The wiring happens in app/ through useFactory providers:
// core/order/usecases/create-order.handler.ts
export class CreateOrderHandler {
constructor(
private readonly orders: OrderRepository,
private readonly tx: TransactionRunner,
private readonly clock: Clock,
) {}
async handle(cmd: CreateOrderCommand): Promise<Order> {
const order = Order.create(cmd.customerId, cmd.items, this.clock);
await this.tx.run(async () => { await this.orders.save(order); });
return order;
}
}
No @Injectable(). No @Inject(). No dependency on @nestjs/common.
adapters/ — input and output
Adapters are the implementation of what core/ describes through interfaces. They split into inbound (in/) and outbound (out/).
Inbound adapters are the entry points into the application:
adapters/in/
http/ # public REST, JWT with user audience
http-admin/ # REST for the admin panel, JWT with admin audience
kafka/ # Kafka consumers as an entry point
cli/ # CLI / batch (if any)
The public API and the admin API are separated deliberately. Each has its own Guard, its own JwtStrategy, its own DTOs. Mixing them in one folder means losing that isolation.
Outbound adapters are calls to external systems. Each system gets its own folder:
adapters/out/
persistence/ # TypeORM — implements OrderRepository
sber/ # Sber's axios client — implements PaymentPort
notifications/ # SMS provider — implements NotificationPort
kafka/ # kafkajs — implements DomainEventPublisher
Why one folder per system: the timeout, retry, and circuit breaker for Sber and for SMS are different settings. A single shared HTTP client for all external calls is a common mistake that makes separate control of behavior impossible.
app/ — the assembly point
app/ is where everything comes together. Only here do the NestJS modules bind ports to implementations:
app/
main.ts # NestFactory.create, enableShutdownHooks
app.module.ts # AppModule: imports all feature modules
config/ # ConfigModule, typed config
Dockerfile
There is no business logic in app/ — only assembly. And nobody imports from app/ — it is the closing node.
Example: how a handler from core/ receives its dependencies through useFactory:
// app/order.module.ts
@Module({
providers: [
{ provide: ORDER_REPOSITORY, useClass: TypeOrmOrderRepository },
{ provide: TX_RUNNER, useClass: PgTransactionRunner },
{ provide: CLOCK, useClass: SystemClock },
{
provide: CreateOrderHandler,
useFactory: (repo, tx, clock) => new CreateOrderHandler(repo, tx, clock),
inject: [ORDER_REPOSITORY, TX_RUNNER, CLOCK],
},
],
exports: [CreateOrderHandler],
})
export class OrderModule {}
CreateOrderHandler does not know that it lives in a NestJS container. It receives its dependencies through the constructor — like an ordinary object.
dependency-cruiser — an automatic boundary check
A folder structure without a tool is a convention on trust. It takes one developer adding import { DataSource } from 'typeorm' to an aggregate file, and the boundary is broken. The tests pass, the IDE says nothing, code review might miss it.
dependency-cruiser solves this: you describe the forbidden transitions in .dependency-cruiser.cjs and run the check in CI as a mandatory step.
// .dependency-cruiser.cjs
module.exports = {
forbidden: [
{
name: 'core-pure',
severity: 'error',
from: { path: '^src/core' },
to: {
path: '^(src/(adapters|app)|node_modules/(@nestjs|typeorm|class-validator|axios|kafkajs))',
},
},
{
name: 'adapters-independent',
severity: 'error',
from: { path: '^src/adapters/in' },
to: { path: '^src/adapters/out' },
},
{
name: 'nobody-depends-on-app',
severity: 'error',
from: { path: '^src/(core|adapters)' },
to: { path: '^src/app' },
},
],
};
Add a script to package.json:
{
"scripts": {
"arch:check": "depcruise --validate .dependency-cruiser.cjs src"
}
}
Run npm run arch:check in CI as a required check — without a green status the merge is blocked. A violation looks like this:
error core-pure: src/core/order/aggregate/order.ts
→ node_modules/@nestjs/common/index.js
You cannot "not notice" it.
Common mistakes
TypeORM in core/: import { DataSource } from 'typeorm' in an aggregate or a handler is an isolation violation. TypeORM belongs only in adapters/out/persistence/; the mapping between domain and ORM objects is in *.mapper.ts.
User and admin in one folder: if the public and administrative REST live in a single adapters/in/http/, they will inevitably start sharing Guards and DTOs. Splitting into http/ and http-admin/ makes this explicit.
@Injectable() on a handler in core/: the handler becomes dependent on @nestjs/common. Use a plain class and a useFactory provider in app/.
An in adapter imports an out adapter: the controller must not reach directly into the repository. Coordination goes through Dispatcher → Handler in core/.
In short
src/splits into three zones:core/(business),adapters/(input/output),app/(assembly).- The dependency arrow:
app → adapters → core.core/knows nothing about the framework or the adapters. - Handlers in
core/are plain classes without@Injectable(). Wiring is throughuseFactoryinapp/. - Each external system gets its own folder in
adapters/out/. Each type of input gets its own folder inadapters/in/. - dependency-cruiser with three rules (
core-pure,adapters-independent,nobody-depends-on-app) run in CI makes the boundaries enforced rather than voluntary.
What to read next
- Ports — Symbol tokens, the port interface, port exceptions in
core/. - Adapters in — NestJS controllers, mapping DTO → command, Dispatcher.
- Adapters out — implementing port interfaces, TypeORM and axios adapters.
- Core layer — aggregates, port interfaces, and plain handlers.
- Architecture tests — the full dependency-cruiser config and a breakdown of errors.