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.
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-cruiserorimport-x/no-restricted-pathsso that a boundary violation fails CI - explain in which modules this strictness pays off and where it is overengineering
Know beforehand
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
- How this differs from controller, service and repository layers
- The dependency rule and the four circles
- The core: entity, ports and use case
- Ports and adapters: in-memory, PostgreSQL, webhook and HTTP
- Tests: the core without a database or HTTP, adapters against one contract
- The dependency rule as a CI check
- The 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.
- 01EntitiesDomain rules valid for any application in the business. They change least often
- 02Use casesApplication-specific rules: they orchestrate entities and declare ports
- 03Interface adaptersControllers, presenters, SQL repositories, external API clients — they convert data between formats
- 04Frameworks and driversWeb server, DB driver, configuration — the “details”, mostly glue code
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
makeCreateLeaduse case, portsLeadRepository,LeadNotifier,Clock - What it may import
applicationanddomain
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:httpserver, thepgpool, 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.
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-alpinemodule: nodenext, as in the layering lessonThe 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.
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 };
}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.
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;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.
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>;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
CreateLeadfunction - 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()andrandomUUID; 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.
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;
}
}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;
}
}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);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.
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);
}
}failNext simulates a failureimport 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}`);
}
}fetch and AbortSignal.timeout from Node.jsThe 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.
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;
}
};
}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.
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}`));npx tsx src/main/server.ts. With DATABASE_URL — PostgreSQL, without it — memory; with LEAD_WEBHOOK_URL — the webhook$ 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)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.
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);
});
});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.
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"),
});
});
});
}LeadRepository port contract: one specification for every adapterimport { 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);
});
});DATABASE_URL is set$ 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)DATABASE_URL, 9 run and two are skippedThe 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.
--- 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 {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”.
/** @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" },
},
};main$ 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)tsc and all tests pass, both linters fail. The project path is shortenedESLint 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.
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." }] },
],
},
},
);Imports between circles
- dependency-cruiser
- Yes, path regular expressions
- ESLint (import-x + no-restricted-imports)
- Yes,
target/fromzones
npm packages and node: in the core
- dependency-cruiser
- Yes, with the same rule
- ESLint (import-x + no-restricted-imports)
- A separate
no-restricted-importsrule
Adapters do not know each other
- dependency-cruiser
- Yes, one rule with a
$1group - 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-cyclerule 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
{
"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"
}
}npm run check is what CI runs on every pull requestIn 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.
Sources
- Robert C. Martin — The Clean Architecture (Clean Coder Blog, 2012)
- Alistair Cockburn — Hexagonal (Ports & Adapters) Architecture
- Martin Fowler — PresentationDomainDataLayering
- dependency-cruiser — Rules reference
- dependency-cruiser — Options reference (tsPreCompilationDeps)
- eslint-plugin-import-x — no-restricted-paths