Architecture
Intermediate 14 min read Architecture

Clean Architecture & Layering

Clean Architecture arranges code into concentric layers — entities, use cases, adapters, frameworks — so business rules depend on nothing external. Dependencies point inward, and the framework, database and UI become replaceable details. It trades some boilerplate for testability and longevity.

clean architecturelayeringdependency inversionuse casesports and adaptershexagonal
Introduction

What is this?

Clean Architecture (and its close relatives hexagonal and onion architecture) organizes an application into rings: the innermost domain entities and use cases, then interface adapters, and finally frameworks and drivers. The Dependency Rule says source-code dependencies always point inward, so your business rules never depend on Express, Postgres or React — those are details you can swap.

Motivation

Why do you need it?

  • Business rules become the heart of the app and can be unit-tested without a database, HTTP server or UI.
  • Frameworks and infrastructure become replaceable: swap Postgres for Mongo, Express for Fastify, without touching business logic.
  • Changes travel inward slowly; a database migration rarely forces edits in a use case.
  • Layers give new engineers a clear map: where to add a feature, where an adapter lives, where the rule goes.
  • Interfaces at the boundaries make mocking trivial, which removes the need for heavy test infrastructure.
Think about it

Mental model

Imagine

A corporate org chart

Think of

Clean Architecture's dependency rule

The CEO (domain) sets policy; managers (use cases) turn it into action; assistants (adapters) talk to the outside world. If the assistants changed, the CEO's policy would not need rewriting — the direction of authority is inward, like the direction of dependencies.

Mechanics

How it works

  • The domain layer defines entities and value objects with zero dependencies on frameworks.
  • Use cases orchestrate a single business operation, depending only on interfaces (ports) they declare.
  • Adapters implement those ports — repositories, controllers, presenters — translating between the core and the outside world.
  • Frameworks sit at the outermost ring and are wired to adapters at startup via dependency injection.
  • At runtime, the flow is inbound: controller -> use case -> repository adapter -> database, all crossing boundaries through interfaces.

Dependencies point inward; the domain knows nothing about frameworks.

User request
Controller adapter
Use case
Domain entity
Repository port + adapter
Database
Reference

Key commands

npm test

Run the unit tests that exercise use cases with in-memory fakes.

npm run typecheck

Verify the dependency rule: the core should compile without importing framework modules.

node --import tsx src/main.ts

Start the app; main.ts is the composition root that wires adapters to ports.

curl -s -X POST localhost:3000/checkout -d '{"customerId":"1","items":[]}'

Drive the whole stack through the controller to verify the wiring.

{"orderId":"abc123"}
npm run lint -- --rule 'no-restricted-imports'

Enforce that domain/use-case layers never import from infrastructure packages.

Hands-on

Examples

A use case depending only on an interface

A use case depending only on an interface
// application/checkout.ts - inside the use-case ring
export interface OrderRepository {
save(order: Order): Promise<void>
}
export class CheckoutUseCase {
constructor(private readonly orders: OrderRepository) {}
async execute(input: { customerId: string; items: Item[] }) {
const order = new Order(input.customerId, input.items)
order.validateStock()
await this.orders.save(order)
return order.id
}
}
// infrastructure/repos/postgres-order-repo.ts - outer ring
export class PostgresOrderRepository implements OrderRepository {
async save(order: Order) {
await pool.query("INSERT INTO orders (id) VALUES ($1)", [order.id])
}
}

CheckoutUseCase declares the OrderRepository port it needs and knows nothing about Postgres, the pool or SQL. The Postgres adapter implements that interface and is injected at startup. You can test the use case with an in-memory fake and swap databases without touching the core.

Real world

In production

  • Fintech platforms isolate settlement rules so regulatory changes touch only the domain layer.
  • A SaaS product supports both Postgres and a read replica: repositories hide the routing decision.
  • Payment integrations swap providers by replacing a single adapter behind the same port.
  • Teams migrate from Express to Fastify or from REST to gRPC by swapping the controller ring only.
Guidelines

Best practices

  • Enforce the dependency rule in CI with import restrictions so the architecture cannot rot silently.
  • Keep the domain layer dependency-free: no ORM entities, no HTTP types, no framework decorators.
  • Put everything into ports: repository, clock, id generator, message bus — the use case should not call statics.
  • Write the composition root (main) as the only place that knows concrete implementations.
  • Start small: clean architecture pays off at the seams; do not abstract a boundary you will never cross.
Watch out

Common mistakes

  • Leaking ORM entities or framework types into the domain, which couples core to the database.
  • Creating dozens of one-method interfaces — over-abstraction without a real second implementation.
  • Putting business logic in controllers and services instead of the domain layer.
  • Treating clean architecture as a folder structure alone while dependencies still point outward.
Root cause analysis

Common issues

Error
Cannot find module 'express' imported from application/use-case
Root cause

A framework import leaked into the core layer, violating the dependency rule.

Fix

Move the HTTP concern to the adapter ring and pass plain DTOs into the use case instead.

Error
TypeError: orders.save is not a function
Root cause

The repository was never injected into the use case at the composition root.

Fix

Wire the concrete PostgresOrderRepository into CheckoutUseCase in main.ts, or inject a fake in tests.

Error
Unit test hangs because it opens a real connection pool
Root cause

The test constructed the production adapter instead of a fake port implementation.

Fix

Define an InMemoryOrderRepository in the test and inject it; keep infrastructure out of unit tests.

When things break

Debugging tips

  • When a test needs a real database, you crossed a boundary — the use case should accept a fake repository.
  • If swapping a framework requires touching core code, check where the framework type leaked in.
  • Use dependency injection or a container at the composition root; debug 'cannot read repository' by checking what main.ts wired.
  • Trace a request from controller to use case to adapter; the failure tells you which ring owns the bug.
Interviews

Interview checkpoint

Practice

Hands-on lab

Refactor a Leaky Controller into Clean Layers
40 min · intermediate
In progress
Objectives
  • Identify framework and database leaks in a controller
  • Introduce a domain entity with no imports
  • Extract a use case that depends only on a repository port
  • Wire an in-memory fake and verify with a unit test

Refactor a Leaky Controller into Clean Layers

40 min · intermediate

0/5 steps
0%
sandbox · Linux sandbox
$
Try these commands
Test yourself

Quick quiz

0/3
1

Which direction must source-code dependencies point in Clean Architecture?

2

In Clean Architecture, your domain entities may import database driver types.

3

You want to unit-test a checkout use case without a database. What is the cleanest approach?

Quick reference

Cheat sheet

Ring responsibilities
Entities / value objectsPure business model
Use casesOne business operation each
AdaptersRepos, controllers, presenters
FrameworksExpress, Postgres, React drivers
Composition root (main)The only place that wires concretes
Rules of thumb
No framework imports in coreEnforce in CI
Ports before implementationsDeclare the interface you need
One adapter per integrationSwap providers by replacing it
DTOs across boundariesNo entity leakage to the edge
Go deeper

Official documentation