Clean Architecture
Concentric rings — Entities, Use Cases, Interface Adapters, Frameworks & Drivers — governed by one rule, source-code dependencies point inward, so business rules never import the web framework or the database; powerful where boundaries matter, and pure overhead in a system too small to have boundaries.
Frameworks and databases change faster than business rules, yet in most codebases the rules are written *in terms of* the framework and the ORM, so every upgrade or migration is a rewrite of the rules. Clean Architecture makes the rules the stable centre and turns everything volatile into a plug-in.
The rings and the one rule
Four rings, inside out. Entities: enterprise-wide rules and the objects that carry them — an Account that refuses to overdraw. Use Cases: application-specific orchestration — TransferFunds loads two accounts, applies the rules, persists both. Interface Adapters: the translators — controllers that turn HTTP into use-case input, presenters that turn output into JSON, gateways that turn a repository interface into SQL. Frameworks & Drivers: the web framework, the database, the message broker, the UI toolkit.
The Dependency Rule: source-code dependencies point inward only. Nothing in an inner ring may name anything in an outer ring — not a class, not a function, not a data format. A use case may not import Express, Prisma or Kafka. When a use case needs to save something, it calls an interface *it* declares, and an outer ring implements that interface. This is dependency inversion applied at architectural scale: the flow of control still goes controller → use case → database, but the *compile-time* arrow from use case to database is reversed, because the database code implements the use case’s interface rather than the use case importing the database.
The difference from plain Layered Architecture is exactly this inversion. In a naive layered app the service imports the repository *class*; in Clean Architecture the use case imports a repository *interface* it owns, and the concrete repository lives outside and imports inward. That single change is what lets you run every rule in a test with no database, and swap Postgres for DynamoDB without opening a use-case file.
A use case with an injected repository
The use case owns the interface. The adapter that implements it lives in an outer ring and is injected at the composition root — the one place in the program (usually main) allowed to know about every ring, because it wires them together. In tests the same use case is constructed with an in-memory implementation and runs in microseconds.
1// inner ring — no imports from any framework2export interface AccountRepository {3 byId(id: string): Promise<Account | null>4 save(a: Account): Promise<void>5}6 7export class TransferFunds {8 constructor(private readonly accounts: AccountRepository) {}9 async execute(from: string, to: string, cents: number): Promise<void> {10 const [src, dst] = await Promise.all([this.accounts.byId(from), this.accounts.byId(to)])11 if (!src || !dst) throw new Error('account_not_found')12 src.withdraw(cents) // entity rule: throws on overdraw13 dst.deposit(cents)14 await this.accounts.save(src)15 await this.accounts.save(dst)16 }17}18 19// outer ring — imports inward, never the reverse20export class PgAccountRepository implements AccountRepository { /* SQL here */ }21export class InMemoryAccountRepository implements AccountRepository { /* Map here */ }22 23// composition root24const transfer = new TransferFunds(new PgAccountRepository(pool))Criticism: when the rings cost more than they protect
Clean Architecture is a bet that the framework and database will change and the business rules will not. When that bet is right — a payment system that has outlived two web frameworks and a database migration — the rings pay for themselves many times over: rules tested in isolation, adapters swapped without fear, a domain that new engineers can read without knowing the stack. When the bet is wrong the rings are dead weight.
The failure is excessive abstraction in small systems. A CRUD service with three tables and no rules beyond "not null" gets an entity, a use case, an input port, an output port, a presenter, a request model, a response model and a gateway interface per endpoint — eight files to return a row. Every one of those files is a place a reader must visit to learn what the code does, and none of them contains a decision. Worse, the discipline of "no framework in the inner rings" pushes people to reinvent what the framework already does well: hand-written validation instead of a schema library, a home-grown mapper instead of the ORM. The system becomes harder to understand *because* it is "clean".
The honest test is the one every abstraction faces: what change does this ring absorb, and how likely is that change? If you cannot name a plausible second implementation of an interface, do not write the interface. If the use case is one query with no rule, let the handler run the query. Start with a domain that imports nothing and a thin shell around it; add rings when a second delivery mechanism or a second storage engine actually appears, not when a diagram suggests they might.
- Useful when boundaries matter: long-lived rules, multiple delivery mechanisms, storage that has changed or will.
- Harmful when the rules are trivial: eight files per endpoint is a reading cost with no protective benefit.
- The composition root is the one legitimate place that knows every ring; keep it small and boring.
Key points
- Entities → Use Cases → Interface Adapters → Frameworks & Drivers; source dependencies point inward and never outward.
- The use case declares the repository interface; the database adapter implements it. Control flows out-to-in, compile-time arrows in-to-out.
- Because inner rings import nothing volatile, every business rule runs in a test with an in-memory adapter in microseconds.
- The bet is that frameworks and databases change and rules do not; where the bet is wrong, the rings are eight files that return one row.
- Write an interface only when you can name its second implementation.
The Dependency Rule, tested
// The port lives in the use-case ring; the adapter outside implements it.
export interface OrderRepository { save(o: Order): Promise<void> }
export class PlaceOrder {
constructor(private readonly orders: OrderRepository) {} // injected
async execute(input: PlaceOrderInput) { /* … */ await this.orders.save(order) }
}How data moves through it
One request or event, hop by hop.
- 1Client → Controller: the request is parsed and validated for shape, then converted into the use case’s input type.
- 2Controller → Use Case:
execute(input)is called; the use case has no idea a web request exists. - 3Use Case → Entities: aggregates are loaded through the repository interface and their rules are applied in memory.
- 4Use Case → Repository interface → Adapter:
save()is dispatched to whichever implementation the composition root injected. - 5Use Case → Presenter → Client: the output model is turned into JSON, HTML or a gRPC message by an outer-ring adapter.
When to use — and when not
- Business rules that will outlive the current framework or database: billing, ledgers, eligibility, scheduling.
- More than one delivery mechanism (HTTP, gRPC, CLI, queue consumer) that must run the same use cases.
- A team large enough that "where does this rule live?" has to have one answer.
- CRUD services where the only rule is the schema; the framework already is the architecture.
- Prototypes and internal tools where reading cost dominates and the storage will never change.
- When you cannot name a second implementation of the interfaces you are about to write.
Tradeoffs
Zero runtime cost — it is all compile-time structure. The cost is indirection and file count; the payoff scales with how long the rules live and how often the edges change.
How it fails
- The Dependency Rule is violated once "just for this feature" — a use case imports the ORM for a convenient query — and within a year nothing is testable without the database.
- Interface explosion: every class gets an interface with exactly one implementation, and refactoring means changing two files for every one.
- Mapping fatigue: request model → input model → entity → output model → response model; a field added to the API touches five types and four mappers.
- The inner ring reinvents the framework — hand-rolled validation, a bespoke query builder — and is buggier than what it replaced.
How it scales
- Does not change how the process scales; a clean codebase is still one deployable behind a load balancer.
- Scales the codebase: use cases are natural seams for Modular Monolith modules, and later for service extraction, because their dependencies are already explicit interfaces.
- Scales the test suite: thousands of use-case tests run without I/O, so the suite stays fast as the domain grows.
How it interacts with databases, queues, caches, APIs and external systems
- Database: behind a gateway interface owned by the use case; the SQL adapter lives in the outermost ring.
- Queue: publishing an event is a port (
EventPublisher) the use case calls; Kafka or SQS is an adapter, and tests use an in-memory list. - Cache: an adapter decorator around the repository adapter, invisible to the use case.
- External APIs: a payment provider is a port with a real adapter and a fake; the fake makes the whole checkout testable offline.
- Frameworks: only the composition root and the adapters may import them; the inner rings compile with no framework installed.