Hexagonal architecture splits the code into three zones: core/ (domain logic), adapters/ (everything external), and app/ (assembly). The idea is good, but without a tool it rests solely on the developer's goodwill.
Why code review alone is not enough
In Java, layer boundaries are protected by the compiler: if core/build.gradle.kts has no dependency on Spring, an org.springframework.* import simply will not compile. Node works differently — all the code lives in a single node_modules, and TypeScript will happily let core/ import @nestjs/common or typeorm. The only way to catch such a violation is a dedicated tool.
Without one, a single stray import { Injectable } from '@nestjs/common' in core/ — and the boundary is broken unnoticed. A reviewer will not always spot it, and accumulated violations are expensive to fix later.
dependency-cruiser
dependency-cruiser is a tool that reads the actual imports in TypeScript and JavaScript and checks them against the rules in a config. If an import breaks a rule, it exits with an error.
The config is named .dependency-cruiser.cjs and lives at the project root. It is in CommonJS format (.cjs) because dependency-cruiser loads it through require() even in ESM projects.
<project-root>/
.dependency-cruiser.cjs
src/
core/<bc>/
adapters/in/http/
adapters/out/persistence/
adapters/out/<system>/
app/
Running it:
npx depcruise --validate .dependency-cruiser.cjs src
Three baseline rules
A minimal config for a Hexagonal project:
// .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' },
},
],
options: {
tsConfig: { fileName: 'tsconfig.json' },
enhancedResolveOptions: { exportsFields: ['exports'] },
},
};
What each rule checks:
core-pure—core/imports nothing infrastructural: not@nestjs/*, nottypeorm, notaxios, nor any other external dependency. The domain layer must be pure TypeScript with no framework.adapters-independent— inbound adapters (adapters/in/*) know nothing about outbound ones (adapters/out/*). An HTTP controller must not grab a database repository directly.nobody-depends-on-app—app/is the assembly point, not a library. Nobody relies on it.
Additional rules as the project grows
When the project gains several bounded contexts or several out adapters, you add refining rules:
{
name: 'adapters-out-independent',
severity: 'error',
from: { path: '^src/adapters/out/([^/]+)/' },
to: { path: '^src/adapters/out/(?!$1/)' },
},
{
name: 'core-no-cross-bc',
severity: 'warn',
from: { path: '^src/core/([^/]+)/' },
to: { path: '^src/core/(?!$1/|shared/)' },
},
The first rule forbids adapters/out/* adapters from referencing each other. If SberPaymentAdapter calls NotificationsAdapter, that is a job for a handler in core/, not a direct import. The second warns when one bounded context directly imports another's aggregates — the interaction should go through a port interface.
Typical violations
@Injectable() in core/
// core/order/usecases/create-order.handler.ts — wrong
import { Injectable } from '@nestjs/common';
@Injectable()
export class CreateOrderHandler { ... }
A domain handler is a plain TypeScript class. The @Injectable() annotation is needed only in adapters. The binding is moved into a useFactory provider in app/order.module.ts.
The controller grabs the repository directly
// adapters/in/http/order.controller.ts — wrong
import { TypeOrmOrderRepository } from 'src/adapters/out/persistence/...';
The controller should know only about the Dispatcher or a use case port from core/. The repository is an implementation detail hidden behind a port interface.
TypeORM decorators in a domain class
// core/order/aggregate/order.ts — wrong
import { Entity, Column } from 'typeorm';
@Entity()
export class Order { ... }
A domain object is a plain TypeScript class. @Entity() and @Column() live only in the ORM entity inside adapters/out/persistence/.
An out adapter calling another out adapter
// adapters/out/sber/sber-payment.adapter.ts — wrong
import { NotificationsAdapter } from 'src/adapters/out/notifications/...';
Coordination is the handler's job. The handler injects PaymentPort and NotificationsPort through Symbol tokens, not the concrete adapters.
Mandatory check in CI
Running the check by hand is not enough: if it does not block the merge, violations get put off "for later" and pile up. You need a mandatory status check.
GitHub Actions:
# .github/workflows/ci.yml
jobs:
architecture-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx depcruise --validate .dependency-cruiser.cjs src
GitLab CI:
architecture-check:
image: node:22-alpine
script:
- npm ci
- npx depcruise --validate .dependency-cruiser.cjs src
rules:
- if: '$CI_PIPELINE_SOURCE == "merge_request_event"'
In the branch settings, architecture-check is marked as a required status check. Without that, the config remains just documentation.
What the output looks like on a violation
error core-pure: src/core/order/usecases/create-order.handler.ts →
node_modules/@nestjs/common/index.js
1 error, 0 warnings
The message shows the specific file and the specific import — it is clear what to fix.
Visualizing the dependency graph
dependency-cruiser can build a graph of all imports as an SVG — handy when getting to know a project or when investigating a complex violation:
npx depcruise --validate .dependency-cruiser.cjs \
--output-type dot src | dot -T svg -o arch.svg
Alternative: eslint-plugin-boundaries
If ESLint is already set up in the project, eslint-plugin-boundaries solves the same task within a single tool:
// eslint.config.mjs
import boundaries from 'eslint-plugin-boundaries';
export default [
{
plugins: { boundaries },
settings: {
'boundaries/elements': [
{ type: 'core', pattern: 'src/core/**' },
{ type: 'adapters-in', pattern: 'src/adapters/in/**' },
{ type: 'adapters-out', pattern: 'src/adapters/out/**' },
{ type: 'app', pattern: 'src/app/**' },
],
},
rules: {
'boundaries/element-types': ['error', {
default: 'disallow',
rules: [
{ from: 'core', allow: [] },
{ from: 'adapters-in', allow: ['core'] },
{ from: 'adapters-out', allow: ['core'] },
{ from: 'app', allow: ['core', 'adapters-in', 'adapters-out'] },
],
}],
},
},
];
If the project is new, it is easier to start with dependency-cruiser: a single .cjs file without extra linter setup. If ESLint already exists and you want a single tool, eslint-plugin-boundaries fits into the existing process.
In short
- In Node there is no compile-time layer isolation — boundary rules require a dedicated tool.
- dependency-cruiser reads the actual imports and compares them against the rules in
.dependency-cruiser.cjs. - Three baseline rules:
core/does not import the framework, inbound adapters do not know outbound ones, nobody depends onapp/. - A single config at the project root, scanning
src/— not scattered files across folders. - Without a mandatory CI check the config does not work: violations get bypassed or postponed.
- eslint-plugin-boundaries is an alternative if ESLint is already set up.
What to read next
- Core layer — why
@Injectable()is forbidden incore/and how a pure domain layer is built. - Ports — the Symbol tokens and interfaces through which
core/talks to adapters. - Bootstrap / Composition root — how
app/assembles everything without leaking dependencies. - When to move to Hexagonal — architecture tests are justified only when the project is complex enough.