How to buildOctober 11, 202612 min read
Software architectureStep 4 of 9

Clean architecture: dependencies point inward, module boundaries

In short

Clean architecture is a way to organize code so that business rules do not depend on the framework, the database or external services. Its central requirement is the dependency rule: source code imports point only inward, from adapters to use cases and entities, and the core talks to the outside world through port interfaces that it declares itself. The TypeScript compiler does not notice violations of this rule, so it is checked by a tool in CI — for example dependency-cruiser or ESLint.

Deep diveLevel: AdvancedClean Architecture

After this you will be able to

  • describe the four circles of clean architecture and state the dependency rule: imports point only inward, the core owns the ports
  • write a TypeScript use case with repository and notification ports and wire in-memory and PostgreSQL adapters in the composition root
  • check both adapters with one contract test and configure dependency-cruiser or import-x/no-restricted-paths so that a boundary violation fails CI
  • explain in which modules this strictness pays off and where it is overengineering

Verified with

Node.js 24 LTS (24.18)TypeScript 5.9.3 (tsc --strict), tsc 7.0.2Vitest 5.0.3, tsx 4.23pg (node-postgres) 8.23, PostgreSQL 17.11 (Docker, postgres:17-alpine)dependency-cruiser 18.6.0ESLint 10.12, typescript-eslint 8.71, eslint-plugin-import-x 4.17.1, eslint-import-resolver-typescript 4.4.5

Checked against docs: October 11, 2026

Contents
  1. 01How this differs from controller, service and repository layers
  2. 02The dependency rule and the four circles
  3. 03The core: entity, ports and use case
  4. 04Ports and adapters: in-memory, PostgreSQL, webhook and HTTP
  5. 05Tests: the core without a database or HTTP, adapters against one contract
  6. 06The dependency rule as a CI check
  7. 07The cost: when it is overengineering

How this differs from controller, service and repository layers

The lesson on backend layers splits a fat handler into a controller, a service and a repository and tests the service with an in-memory repository. That is already half of clean architecture. The other half is one rule and the question of who owns the interfaces. We do not repeat the layering walkthrough here; we show what changes when that rule is applied all the way.

The classic layered scheme, as Martin Fowler describes it, points dependencies top-down: presentation depends on the domain, the domain depends on the data source. Fowler also mentions a common variation where the domain does not depend on its data sources thanks to an intermediate layer — often called a hexagonal architecture. In “The Clean Architecture” (2012) Robert Martin reduced hexagonal, onion and several similar approaches to one requirement: source code dependencies point only inward, and nothing in an inner circle knows names from an outer one — no functions, classes or variables.

Where dependencies point

Layers (the layering lesson)
Top-down, towards storage
Clean architecture
Inward, towards business rules

Who declares the storage interface

Layers (the layering lesson)
Usually the data layer, next to the implementation
Clean architecture
The use case layer — as a port it needs

May the service know a DB driver error type, a framework Request, a table row

Layers (the layering lesson)
In practice it often does
Clean architecture
No: only simple data structures cross the boundary

Where implementations are chosen

Layers (the layering lesson)
Wherever convenient
Clean architecture
In one composition root

What protects the boundary

Layers (the layering lesson)
Code review
Clean architecture
Core tests and a dependency linter in CI

The dependency rule and the four circles

Martin draws a system as concentric circles. The closer to the centre, the higher the level of abstraction: outer circles are mechanisms, inner circles are policies — business rules.

  1. 01EntitiesDomain rules valid for any application in the business. They change least often
  2. 02Use casesApplication-specific rules: they orchestrate entities and declare ports
  3. 03Interface adaptersControllers, presenters, SQL repositories, external API clients — they convert data between formats
  4. 04Frameworks and driversWeb server, DB driver, configuration — the “details”, mostly glue code
Source code dependencies point from the outer circle to the centre. There may be more than four circles — only the rule is mandatory

Entities

Folder in the example
src/domain
What lives there
Lead, createLead, normalizePhone, DomainError
What it may import
Only domain

Use cases

Folder in the example
src/application
What lives there
The makeCreateLead use case, ports LeadRepository, LeadNotifier, Clock
What it may import
application and domain

Adapters

Folder in the example
src/adapters
What lives there
In-memory and PostgreSQL repositories, webhook notifications, the HTTP controller
What it may import
Inner circles and npm packages

Frameworks and drivers

Folder in the example
src/main
What lives there
The node:http server, the pg pool, choosing implementations
What it may import
Everything

How does a use case save a lead to PostgreSQL if it may not know about PostgreSQL? The flow of control does go outward: use case → repository → database. The source code dependency is reversed with the dependency inversion principle (the D in SOLID): the use case declares the interface it needs, and the outer circle implements it. Martin puts it this way: polymorphism creates source code dependencies that oppose the flow of control exactly where the flow crosses a boundary.

Simple data structures cross the boundary: function arguments, DTOs, plain objects. Not table rows from a driver and not a framework request object — otherwise the inner circle starts depending on their format. That is why the PostgreSQL adapter below turns the created_at column into a createdAt field before the data reaches the use case.

The core: entity, ports and use case

The example is a lead from a website form, but with a different scenario from the layering lesson. Rules: the name is 1–100 characters; the phone is normalized to +7XXXXXXXXXX; resubmitting the form from the same number within 10 minutes does not create a second lead; after saving, the team gets a notification, and a failed notification must not lose the lead.

bash
npm init -y && npm pkg set type=module
npm install pg@8.23.1
npm install -D typescript@5.9.3 @types/node@24 @types/pg@8 vitest@5.0.3 tsx@4
npm install -D dependency-cruiser@18.6.0 eslint@10.12.0 typescript-eslint@8.71.1 \
  eslint-plugin-import-x@4.17.1 eslint-import-resolver-typescript@4.4.5
docker run -d --rm --name kb-pg-chistaya-arhitektura -e POSTGRES_PASSWORD=test -p 127.0.0.1:55471:5432 postgres:17-alpine
Example project: dependencies and PostgreSQL 17 in Docker. tsconfig uses strict mode and module: nodenext, as in the layering lesson

The entity holds rules valid for any application: the phone format does not depend on whether the lead came from the website, a bot or a spreadsheet import. The file has no imports at all.

src/domain/lead.ts
export class DomainError extends Error {
  constructor(
    readonly code: string,
    message: string,
  ) {
    super(message);
  }
}

export type Lead = {
  readonly id: string;
  readonly name: string;
  readonly phone: string;
  readonly createdAt: Date;
};

// Kazakhstan mobile numbers: "8 701 123 45 67", "+7 (701) 123-45-67" → "+77011234567"
export function normalizePhone(raw: string): string {
  const digits = raw.replace(/\D/g, "");
  const national = digits.length === 11 && /^[78]/.test(digits) ? digits.slice(1) : digits;
  if (!/^7\d{9}$/.test(national)) {
    throw new DomainError("LEAD_INVALID_PHONE", "Phone must be a +7 mobile number");
  }
  return `+7${national}`;
}

export function createLead(props: { id: string; name: string; phone: string; now: Date }): Lead {
  const name = props.name.trim();
  if (name.length === 0 || name.length > 100) {
    throw new DomainError("LEAD_INVALID_NAME", "Name must be 1–100 characters");
  }
  return { id: props.id, name, phone: normalizePhone(props.phone), createdAt: props.now };
}
Entity and domain error: the core imports nothing

Ports are interfaces declared by the use case layer. In Alistair Cockburn's terms they are secondary (driven) ports: the application itself drives the conversation — it asks for data or sends a notification. Time and the ID generator are ports too: randomUUID from node:crypto would tie the core to Node.js, and new Date() inside the use case would make tests depend on the clock.

src/application/ports.ts
import type { Lead } from "../domain/lead.js";

// Driven (secondary) ports: the use case owns these interfaces,
// the outer layer implements them.
export interface LeadRepository {
  save(lead: Lead): Promise<void>;
  findLatestByPhone(phone: string): Promise<Lead | null>;
}

export type NewLeadNotice = { leadId: string; name: string; phone: string };

export interface LeadNotifier {
  newLead(notice: NewLeadNotice): Promise<void>;
}

export type Clock = () => Date;
export type IdGenerator = () => string;
Ports belong to the use cases: adapters adapt to them, not the other way round

A use case is a unit of behaviour: one scenario, one function with an explicit input and output. Here it is a factory that takes dependencies and returns a function; a class with a constructor would work the same way.

src/application/create-lead.ts
import { createLead, normalizePhone } from "../domain/lead.js";
import type { Clock, IdGenerator, LeadNotifier, LeadRepository } from "./ports.js";

export type CreateLeadInput = { name: string; phone: string };
export type CreateLeadResult = { leadId: string; duplicate: boolean; notified: boolean };

const REPEAT_WINDOW_MS = 10 * 60 * 1000;

export type CreateLeadDeps = {
  leads: LeadRepository;
  notifier: LeadNotifier;
  clock: Clock;
  newId: IdGenerator;
  onNotifyError?: (error: unknown) => void;
};

// Use case: one application-specific scenario. It orchestrates the entity
// and the ports and knows nothing about HTTP, SQL or messengers.
export function makeCreateLead(deps: CreateLeadDeps) {
  return async function createLeadUseCase(input: CreateLeadInput): Promise<CreateLeadResult> {
    const now = deps.clock();

    // A double-submitted form must not create a second lead
    const latest = await deps.leads.findLatestByPhone(normalizePhone(input.phone));
    if (latest && now.getTime() - latest.createdAt.getTime() < REPEAT_WINDOW_MS) {
      return { leadId: latest.id, duplicate: true, notified: false };
    }

    const lead = createLead({ id: deps.newId(), name: input.name, phone: input.phone, now });
    await deps.leads.save(lead);

    // The lead is already saved: a failed notification must not lose it
    try {
      await deps.notifier.newLead({ leadId: lead.id, name: lead.name, phone: lead.phone });
      return { leadId: lead.id, duplicate: false, notified: true };
    } catch (error) {
      deps.onNotifyError?.(error);
      return { leadId: lead.id, duplicate: false, notified: false };
    }
  };
}

export type CreateLead = ReturnType<typeof makeCreateLead>;
The “create lead” use case: no HTTP, no SQL, no messenger name

Ports and adapters: in-memory, PostgreSQL, webhook and HTTP

Cockburn described the hexagonal architecture (also called “ports and adapters”) in 2005. Its intent: let an application be driven equally by users, programs, automated tests or batch scripts, and be developed and tested in isolation from its run-time devices and databases. A port is a “purposeful conversation” with the outside world, and one port usually has several adapters: an SQL database, a file, an in-memory database for tests. Cockburn splits them into primary (driving) ones — whoever starts the application — and secondary (driven) ones — whatever the application calls itself.

Primary: who starts the use case

Port
The CreateLead function
Adapters in the example
HTTP controller, a Vitest test; later a bot or a CSV import

Secondary: what the use case calls

Port
LeadRepository
Adapters in the example
InMemoryLeadRepository, PgLeadRepository

Secondary

Port
LeadNotifier
Adapters in the example
InMemoryNotifier, WebhookNotifier

Secondary

Port
Clock, IdGenerator
Adapters in the example
() => new Date() and randomUUID; fixed values in tests

The in-memory adapter is not a temporary stub but a full implementation of the port: Cockburn explicitly names the in-memory database as one of the most important data-side adapters. It lets you run the application and the tests without PostgreSQL.

src/adapters/persistence/in-memory-lead-repository.ts
import type { LeadRepository } from "../../application/ports.js";
import type { Lead } from "../../domain/lead.js";

export class InMemoryLeadRepository implements LeadRepository {
  readonly rows: Lead[] = [];

  async save(lead: Lead): Promise<void> {
    this.rows.push({ ...lead });
  }

  async findLatestByPhone(phone: string): Promise<Lead | null> {
    const found = this.rows
      .filter((lead) => lead.phone === phone)
      .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0];
    return found ? { ...found } : null;
  }
}
In-memory repository: implements the same port as PostgreSQL
src/adapters/persistence/pg-lead-repository.ts
import type pg from "pg";
import type { LeadRepository } from "../../application/ports.js";
import type { Lead } from "../../domain/lead.js";

type LeadRow = { id: string; name: string; phone: string; created_at: Date };

// The row format stays here: the use case only ever sees a Lead
const toLead = (row: LeadRow): Lead => ({
  id: row.id,
  name: row.name,
  phone: row.phone,
  createdAt: row.created_at,
});

export class PgLeadRepository implements LeadRepository {
  constructor(private readonly db: pg.Pool) {}

  async save(lead: Lead): Promise<void> {
    await this.db.query(
      "INSERT INTO leads (id, name, phone, created_at) VALUES ($1, $2, $3, $4)",
      [lead.id, lead.name, lead.phone, lead.createdAt],
    );
  }

  async findLatestByPhone(phone: string): Promise<Lead | null> {
    const { rows } = await this.db.query<LeadRow>(
      `SELECT id, name, phone, created_at FROM leads
       WHERE phone = $1 ORDER BY created_at DESC LIMIT 1`,
      [phone],
    );
    return rows[0] ? toLead(rows[0]) : null;
  }
}
PostgreSQL repository: SQL and the row format never leave this file
schema.sql
CREATE TABLE leads (
  id uuid PRIMARY KEY,
  name text NOT NULL,
  phone text NOT NULL,
  created_at timestamptz NOT NULL
);

CREATE INDEX leads_phone_created_idx ON leads (phone, created_at DESC);
Table schema and an index for the “latest lead by phone” query

The notification port gets two adapters as well. The test fake records what was sent and can simulate a failure. The production adapter posts JSON to a webhook — a CRM, a bot or an automation tool; the URL comes from an environment variable and a timeout limits the wait. Retries and a queue on failure are the next level, covered in the lesson on events and queues; here the use case simply returns notified: false.

src/adapters/notifications/in-memory-notifier.ts
import type { LeadNotifier, NewLeadNotice } from "../../application/ports.js";

export class InMemoryNotifier implements LeadNotifier {
  readonly sent: NewLeadNotice[] = [];
  failNext = false;

  async newLead(notice: NewLeadNotice): Promise<void> {
    if (this.failNext) {
      this.failNext = false;
      throw new Error("notifier is down");
    }
    this.sent.push(notice);
  }
}
Notifications for tests: record what was sent, failNext simulates a failure
src/adapters/notifications/webhook-notifier.ts
import type { LeadNotifier, NewLeadNotice } from "../../application/ports.js";

// Sends the notice as JSON to a webhook: a CRM, a chat bot, an automation tool
export class WebhookNotifier implements LeadNotifier {
  constructor(
    private readonly url: string,
    private readonly timeoutMs = 3000,
  ) {}

  async newLead(notice: NewLeadNotice): Promise<void> {
    const response = await fetch(this.url, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ event: "lead.created", ...notice }),
      signal: AbortSignal.timeout(this.timeoutMs),
    });
    if (!response.ok) throw new Error(`Webhook answered ${response.status}`);
  }
}
Webhook notifications: global fetch and AbortSignal.timeout from Node.js

The HTTP controller is a primary adapter: it turns the request body into the use case input, and the result and domain errors into status codes. It does not depend on a web framework, so it moves easily from node:http to Express or Fastify. Input validation is minimal here; Zod schemas and a uniform error format are in the Express REST API lesson.

src/adapters/http/create-lead-controller.ts
import type { CreateLead } from "../../application/create-lead.js";
import { DomainError } from "../../domain/lead.js";

export type HttpResponse = { status: number; body: unknown };

// Driving (primary) adapter: turns an HTTP body into a use case call
// and the result into a status code. It does not depend on a web framework.
export function makeCreateLeadController(createLead: CreateLead) {
  return async function handle(body: unknown): Promise<HttpResponse> {
    const { name, phone } = (body ?? {}) as Record<string, unknown>;
    if (typeof name !== "string" || typeof phone !== "string") {
      return { status: 400, body: { code: "BAD_REQUEST", message: "name and phone are required" } };
    }
    try {
      const result = await createLead({ name, phone });
      return { status: result.duplicate ? 200 : 201, body: result };
    } catch (error) {
      if (error instanceof DomainError) {
        return { status: 422, body: { code: error.code, message: error.message } };
      }
      throw error;
    }
  };
}
Primary adapter: HTTP body → use case input, result → status code

The last file is the composition root. It is the outermost circle: only it knows every concrete adapter and picks them by environment variables. The rest of the code does not depend on that choice.

src/main/server.ts
import { randomUUID } from "node:crypto";
import { createServer } from "node:http";
import pg from "pg";
import { makeCreateLeadController } from "../adapters/http/create-lead-controller.js";
import { InMemoryNotifier } from "../adapters/notifications/in-memory-notifier.js";
import { WebhookNotifier } from "../adapters/notifications/webhook-notifier.js";
import { InMemoryLeadRepository } from "../adapters/persistence/in-memory-lead-repository.js";
import { PgLeadRepository } from "../adapters/persistence/pg-lead-repository.js";
import { makeCreateLead } from "../application/create-lead.js";

// Composition root: the only module that knows every concrete adapter
const leads = process.env.DATABASE_URL
  ? new PgLeadRepository(new pg.Pool({ connectionString: process.env.DATABASE_URL }))
  : new InMemoryLeadRepository();
const notifier = process.env.LEAD_WEBHOOK_URL
  ? new WebhookNotifier(process.env.LEAD_WEBHOOK_URL)
  : new InMemoryNotifier();

const createLead = makeCreateLead({
  leads,
  notifier,
  clock: () => new Date(),
  newId: randomUUID,
  onNotifyError: (error) => console.error("lead notification failed", error),
});
const handleCreateLead = makeCreateLeadController(createLead);

// Frameworks and drivers: node:http here; Express or Fastify would change only this part
const server = createServer(async (req, res) => {
  if (req.method !== "POST" || req.url !== "/leads") {
    res.writeHead(404).end();
    return;
  }
  try {
    let raw = "";
    for await (const chunk of req) raw += chunk;
    const { status, body } = await handleCreateLead(raw ? JSON.parse(raw) : {});
    res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
  } catch (error) {
    console.error(error);
    res.writeHead(500, { "Content-Type": "application/json" });
    res.end(JSON.stringify({ code: "INTERNAL" }));
  }
});

const port = Number(process.env.PORT ?? 3000);
server.listen(port, () => console.log(`Listening on http://localhost:${port}`));
Run: npx tsx src/main/server.ts. With DATABASE_URL — PostgreSQL, without it — memory; with LEAD_WEBHOOK_URL — the webhook
terminal
$ DATABASE_URL=postgres://postgres:test@127.0.0.1:55471/postgres PORT=3471 npx tsx src/main/server.ts
Listening on http://localhost:3471

$ curl -i -X POST localhost:3471/leads -H 'Content-Type: application/json' \
    -d '{"name":"Aida","phone":"8 701 123 45 67"}'
HTTP/1.1 201 Created
{"leadId":"ff34b10e-571a-4d35-92b1-f60349164c2a","duplicate":false,"notified":true}

$ curl -i -X POST localhost:3471/leads -H 'Content-Type: application/json' \
    -d '{"name":"Aida","phone":"+77011234567"}'
HTTP/1.1 200 OK
{"leadId":"ff34b10e-571a-4d35-92b1-f60349164c2a","duplicate":true,"notified":false}

$ curl -i -X POST localhost:3471/leads -H 'Content-Type: application/json' \
    -d '{"name":"Aida","phone":"12345"}'
HTTP/1.1 422 Unprocessable Entity
{"code":"LEAD_INVALID_PHONE","message":"Phone must be a +7 mobile number"}

$ docker exec kb-pg-chistaya-arhitektura psql -U postgres -c "SELECT name, phone FROM leads"
 name |    phone
------+--------------
 Aida | +77011234567
(1 row)
Real responses from the app on PostgreSQL 17: creation, a repeat within 10 minutes and an entity error (response headers trimmed)

Tests: the core without a database or HTTP, adapters against one contract

The use case is tested with in-memory adapters and a fixed clock. The test starts no server and no database, so each policy — phone normalization, the repeat window, a notification failure — is checked separately and in milliseconds.

test/create-lead.test.ts
import { beforeEach, describe, expect, it } from "vitest";
import { InMemoryNotifier } from "../src/adapters/notifications/in-memory-notifier.js";
import { InMemoryLeadRepository } from "../src/adapters/persistence/in-memory-lead-repository.js";
import { type CreateLead, makeCreateLead } from "../src/application/create-lead.js";
import { DomainError } from "../src/domain/lead.js";

describe("create lead use case", () => {
  let now: Date;
  let ids: number;
  let leads: InMemoryLeadRepository;
  let notifier: InMemoryNotifier;
  let errors: unknown[];
  let createLead: CreateLead;

  beforeEach(() => {
    now = new Date("2026-10-11T09:00:00Z");
    ids = 0;
    leads = new InMemoryLeadRepository();
    notifier = new InMemoryNotifier();
    errors = [];
    createLead = makeCreateLead({
      leads,
      notifier,
      clock: () => now,
      newId: () => `lead-${++ids}`,
      onNotifyError: (error) => errors.push(error),
    });
  });

  it("saves a lead with a normalized phone and notifies the team", async () => {
    const result = await createLead({ name: "  Aida ", phone: "8 701 123 45 67" });

    expect(result).toEqual({ leadId: "lead-1", duplicate: false, notified: true });
    expect(leads.rows).toEqual([
      { id: "lead-1", name: "Aida", phone: "+77011234567", createdAt: now },
    ]);
    expect(notifier.sent).toEqual([{ leadId: "lead-1", name: "Aida", phone: "+77011234567" }]);
  });

  it("treats a repeat within 10 minutes as the same lead", async () => {
    await createLead({ name: "Aida", phone: "+7 701 123 45 67" });
    now = new Date("2026-10-11T09:05:00Z");

    const repeat = await createLead({ name: "Aida", phone: "87011234567" });

    expect(repeat).toEqual({ leadId: "lead-1", duplicate: true, notified: false });
    expect(leads.rows).toHaveLength(1);
    expect(notifier.sent).toHaveLength(1);
  });

  it("creates a new lead after the repeat window", async () => {
    await createLead({ name: "Aida", phone: "+77011234567" });
    now = new Date("2026-10-11T09:11:00Z");

    const later = await createLead({ name: "Aida", phone: "+77011234567" });

    expect(later.duplicate).toBe(false);
    expect(leads.rows).toHaveLength(2);
  });

  it("keeps the lead when the notifier fails", async () => {
    notifier.failNext = true;

    const result = await createLead({ name: "Aida", phone: "+77011234567" });

    expect(result).toEqual({ leadId: "lead-1", duplicate: false, notified: false });
    expect(leads.rows).toHaveLength(1);
    expect(errors).toHaveLength(1);
  });

  it("rejects invalid data with a domain error and saves nothing", async () => {
    await expect(createLead({ name: " ", phone: "+77011234567" })).rejects.toMatchObject({
      code: "LEAD_INVALID_NAME",
    });
    await expect(createLead({ name: "Aida", phone: "12345" })).rejects.toBeInstanceOf(DomainError);
    expect(leads.rows).toHaveLength(0);
  });
});
Use case tests: rules checked without HTTP, a database or the real clock

In-memory adapters carry a risk: the fake behaves differently from the real database, the use case tests are green and production is not. The defence is a port contract test: one set of checks that every implementation must pass. If the PostgreSQL adapter returns something other than the latest lead, its test fails, not the scenario in front of a customer.

test/lead-repository.contract.ts
import { describe, expect, it } from "vitest";
import type { LeadRepository } from "../src/application/ports.js";

// One behaviour spec for every adapter of the LeadRepository port
export function leadRepositoryContract(name: string, make: () => Promise<LeadRepository>) {
  describe(`LeadRepository contract: ${name}`, () => {
    it("returns null when the phone is unknown", async () => {
      const repo = await make();
      expect(await repo.findLatestByPhone("+77000000000")).toBeNull();
    });

    it("returns the most recent lead for a phone", async () => {
      const repo = await make();
      const phone = "+77011234567";
      await repo.save({
        id: "6f1d1c3e-8a7b-4a51-9a59-0c6e8f0a0001",
        name: "Old",
        phone,
        createdAt: new Date("2026-10-01T10:00:00Z"),
      });
      await repo.save({
        id: "6f1d1c3e-8a7b-4a51-9a59-0c6e8f0a0002",
        name: "New",
        phone,
        createdAt: new Date("2026-10-11T10:00:00Z"),
      });

      expect(await repo.findLatestByPhone(phone)).toEqual({
        id: "6f1d1c3e-8a7b-4a51-9a59-0c6e8f0a0002",
        name: "New",
        phone,
        createdAt: new Date("2026-10-11T10:00:00Z"),
      });
    });
  });
}
The LeadRepository port contract: one specification for every adapter
test/repositories.test.ts
import { readFileSync } from "node:fs";
import pg from "pg";
import { afterAll, beforeAll, describe } from "vitest";
import { InMemoryLeadRepository } from "../src/adapters/persistence/in-memory-lead-repository.js";
import { PgLeadRepository } from "../src/adapters/persistence/pg-lead-repository.js";
import { leadRepositoryContract } from "./lead-repository.contract.js";

leadRepositoryContract("in-memory", async () => new InMemoryLeadRepository());

const url = process.env.DATABASE_URL;

describe.runIf(url)("PostgreSQL adapter", () => {
  const pool = new pg.Pool({ connectionString: url });

  beforeAll(async () => {
    await pool.query("DROP TABLE IF EXISTS leads");
    await pool.query(readFileSync(new URL("../schema.sql", import.meta.url), "utf8"));
  });
  afterAll(() => pool.end());

  leadRepositoryContract("postgres", async () => {
    await pool.query("TRUNCATE leads");
    return new PgLeadRepository(pool);
  });
});
The contract against two adapters; PostgreSQL only when DATABASE_URL is set
terminal
$ DATABASE_URL=postgres://postgres:test@127.0.0.1:55471/postgres npx vitest run --reporter=verbose

 RUN  v5.0.3 …

 ✓ test/create-lead.test.ts > create lead use case > saves a lead with a normalized phone and notifies the team 2ms
 ✓ test/create-lead.test.ts > create lead use case > treats a repeat within 10 minutes as the same lead 1ms
 ✓ test/create-lead.test.ts > create lead use case > creates a new lead after the repeat window 0ms
 ✓ test/create-lead.test.ts > create lead use case > keeps the lead when the notifier fails 0ms
 ✓ test/create-lead.test.ts > create lead use case > rejects invalid data with a domain error and saves nothing 1ms
 ✓ test/adapters.test.ts > HTTP controller > maps results and domain errors to status codes 2ms
 ✓ test/adapters.test.ts > WebhookNotifier > posts the notice as JSON and fails on a non-2xx answer 40ms
 ✓ test/repositories.test.ts > LeadRepository contract: in-memory > returns null when the phone is unknown 1ms
 ✓ test/repositories.test.ts > LeadRepository contract: in-memory > returns the most recent lead for a phone 1ms
 ✓ test/repositories.test.ts > PostgreSQL adapter > LeadRepository contract: postgres > returns null when the phone is unknown 6ms
 ✓ test/repositories.test.ts > PostgreSQL adapter > LeadRepository contract: postgres > returns the most recent lead for a phone 10ms

 Test Files  3 passed (3)
      Tests  11 passed (11)
Real output: 11 tests, including the contract on PostgreSQL 17. Without DATABASE_URL, 9 run and two are skipped

The project also has a test for the HTTP controller and one for the webhook adapter with a local HTTP server — both are visible in the output. The resulting shape: many fast core tests, a few tests per adapter and a minimum of end-to-end ones. Integration tests with a database, fixtures and data cleanup are covered in the backend testing lesson.

The dependency rule as a CI check

Cockburn describes the typical failure of layered systems: a new layer promises “no business logic”, but there is no mechanism to detect a violation, and a few years later the layer is cluttered with logic again. Clean architecture without an automated check repeats that story. The compiler will not help: importing a database driver into a use case is valid TypeScript.

Here are two typical violations: a “convenient” ID generator in the entity and PostgreSQL error handling right inside the use case.

diff
--- a/src/domain/lead.ts
+++ b/src/domain/lead.ts
@@ -1,3 +1,7 @@
+import { randomUUID } from "node:crypto";
+
+export const newLeadId = (): string => randomUUID();
+
 export class DomainError extends Error {
   constructor(
     readonly code: string,
--- a/src/application/create-lead.ts
+++ b/src/application/create-lead.ts
@@ -1,3 +1,4 @@
+import { DatabaseError } from "pg";
 import { createLead, normalizePhone } from "../domain/lead.js";
 import type { Clock, IdGenerator, LeadNotifier, LeadRepository } from "./ports.js";
 
@@ -28,7 +29,15 @@
     }
 
     const lead = createLead({ id: deps.newId(), name: input.name, phone: input.phone, now });
-    await deps.leads.save(lead);
+    try {
+      await deps.leads.save(lead);
+    } catch (error) {
+      // 23505 = unique_violation: a PostgreSQL detail inside the use case
+      if (error instanceof DatabaseError && error.code === "23505") {
+        return { leadId: lead.id, duplicate: true, notified: false };
+      }
+      throw error;
+    }
 
     // The lead is already saved: a failed notification must not lose it
     try {
Violations that both the compiler and the tests let through

dependency-cruiser checks the import graph against rules from its configuration. Rules are regular expressions for the “from” and “to” paths; the $1 group lets you say “an adapter may only import files from its own folder”.

.dependency-cruiser.cjs
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
  forbidden: [
    {
      name: "domain-is-pure",
      comment: "Entities know nothing outside the domain: no app code, no npm, no node: modules",
      severity: "error",
      from: { path: "^src/domain/" },
      to: { pathNot: "^src/domain/" },
    },
    {
      name: "application-depends-only-on-domain",
      comment: "Use cases see entities and their own ports, never adapters, frameworks or drivers",
      severity: "error",
      from: { path: "^src/application/" },
      to: { pathNot: "^src/(application|domain)/" },
    },
    {
      name: "adapters-do-not-know-each-other",
      comment: "The SQL adapter must not call the webhook adapter: they meet only in main",
      severity: "error",
      from: { path: "^src/adapters/([^/]+)/" },
      to: { path: "^src/adapters/", pathNot: "^src/adapters/$1/" },
    },
    {
      name: "nobody-imports-main",
      comment: "The composition root is the outermost circle",
      severity: "error",
      from: { pathNot: "^src/main/" },
      to: { path: "^src/main/" },
    },
    {
      name: "no-circular",
      severity: "error",
      from: {},
      to: { circular: true },
    },
    {
      name: "not-to-unresolvable",
      severity: "error",
      from: {},
      to: { couldNotResolve: true },
    },
  ],
  options: {
    doNotFollow: { path: "node_modules" },
    // Count `import type` too: a type from an outer circle is still a dependency
    tsPreCompilationDeps: true,
    tsConfig: { fileName: "tsconfig.json" },
  },
};
The dependency rule as checkable rules: a pure core, use cases see only the domain, adapters do not know each other, nobody imports main
terminal
$ npx tsc --noEmit && echo "tsc: ok"
tsc: ok
$ npx depcruise src --config .dependency-cruiser.cjs

  error domain-is-pure: src/domain/lead.ts → crypto
  error application-depends-only-on-domain: src/application/create-lead.ts → node_modules/pg/lib/index.js

x 2 dependency violations (2 errors, 0 warnings). 12 modules, 23 dependencies cruised.

$ npx eslint src

…\src\application\create-lead.ts
  1:1  error  'pg' import is restricted from being used by a pattern. The core imports only relative paths  no-restricted-imports

…\src\domain\lead.ts
  1:1  error  'node:crypto' import is restricted from being used by a pattern. The core imports only relative paths  no-restricted-imports

✖ 2 problems (2 errors, 0 warnings)
Real output on the code with violations: tsc and all tests pass, both linters fail. The project path is shortened

ESLint gives the same protection right in the editor. The import-x/no-restricted-paths rule forbids imports between folder zones, and the built-in no-restricted-imports with a regular expression forbids any non-relative import in the core — npm packages and node: modules. For ESLint to understand imports with a .js extension, it needs the TypeScript resolver.

eslint.config.js
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
import { importX } from "eslint-plugin-import-x";
import tseslint from "typescript-eslint";

export default tseslint.config(
  {
    files: ["src/**/*.ts"],
    languageOptions: { parser: tseslint.parser },
    plugins: { "import-x": importX },
    // Resolves "./ports.js" to ports.ts, as TypeScript does
    settings: { "import-x/resolver-next": [createTypeScriptImportResolver()] },
    rules: {
      // Local files: inner circles must not reach outward
      "import-x/no-restricted-paths": [
        "error",
        {
          zones: [
            { target: "./src/domain", from: "./src", except: ["./domain"] },
            { target: "./src/application", from: "./src/adapters" },
            { target: "./src/application", from: "./src/main" },
            { target: "./src/adapters", from: "./src/main" },
          ],
        },
      ],
    },
  },
  {
    // Packages: the core imports nothing from npm or node:
    files: ["src/domain/**/*.ts", "src/application/**/*.ts"],
    rules: {
      "no-restricted-imports": [
        "error",
        { patterns: [{ regex: "^[^.]", message: "The core imports only relative paths." }] },
      ],
    },
  },
);
ESLint 10 + eslint-plugin-import-x: zones for your own folders and no packages in the core

Imports between circles

dependency-cruiser
Yes, path regular expressions
ESLint (import-x + no-restricted-imports)
Yes, target / from zones

npm packages and node: in the core

dependency-cruiser
Yes, with the same rule
ESLint (import-x + no-restricted-imports)
A separate no-restricted-imports rule

Adapters do not know each other

dependency-cruiser
Yes, one rule with a $1 group
ESLint (import-x + no-restricted-imports)
A separate zone for every pair of folders

Cycles

dependency-cruiser
circular: true — verified
ESLint (import-x + no-restricted-imports)
The import-x/no-cycle rule exists, not configured in the example

import type

dependency-cruiser
Only with tsPreCompilationDeps: true
ESLint (import-x + no-restricted-imports)
Counted — verified on the example

Editor feedback

dependency-cruiser
No, run as a command
ESLint (import-x + no-restricted-imports)
Yes
package.json
{
  "scripts": {
    "test": "vitest run",
    "typecheck": "tsc --noEmit",
    "lint": "eslint src",
    "lint:arch": "depcruise src --config .dependency-cruiser.cjs",
    "check": "npm run typecheck && npm run lint && npm run lint:arch && npm test"
  }
}
Project scripts: npm run check is what CI runs on every pull request

In our experience one tool in CI is enough; ESLint is handier when a linter is already set up, dependency-cruiser when there are many rules or you need rules between modules, not just between circles.

The cost: when it is overengineering

Clean architecture adds files and indirection: an interface where you could call a function; mapping where you could pass a table row; a composition root where an import would do. It does not pay off everywhere. Martin himself stresses that the four circles are a schematic, not a law; only the dependency rule is mandatory.

Pays off

  • The rules are complex and important: calculations, statuses, limits, permissions — they need tests without infrastructure
  • One use case is started from several places: HTTP, a bot, a queue, cron, an import
  • External services may change or there are several: payment providers, messengers, CRMs
  • The system evolves for years with several teams — the boundary must hold without verbal agreements

Overengineering

  • A CRUD lookup table: the use case only forwards data to the database, there are no rules
  • A prototype or MVP that tests a hypothesis and may be thrown away
  • A port with a single implementation that will not change and does not slow tests down
  • Adding a form field means editing five files in four folders

Fowler adds an important caveat about scale: presentation-domain-data layering should be applied at a relatively small granularity. As an application grows, the top level is better split into domain-oriented modules, each layered internally. Not one src/domain for the whole system, but leads/domain, billing/domain and so on — and that is where a modular monolith begins.

In our experience a workable progression is: start with the layers from the layering lesson; extract a port as soon as an external service slows tests down or may change; move to the full scheme with a CI check for modules with rich rules. One project can mix approaches: a strict core for orders and payments and plain CRUD for lookup tables.

Next: the lesson on the modular monolith and microservices shows how the same boundaries are drawn between modules of a system and when they are worth turning into separate services, and the integration adapter recipe shows how to put an external API behind a port with retries and timeouts.

How this was verified6 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.