← Back to the section

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-purecore/ imports nothing infrastructural: not @nestjs/*, not typeorm, not axios, 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-appapp/ 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 on app/.
  • 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.
  • Core layer — why @Injectable() is forbidden in core/ 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.