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.
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.
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.
Mental model
A corporate org chart
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.
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.
Key commands
npm testRun the unit tests that exercise use cases with in-memory fakes.
npm run typecheckVerify the dependency rule: the core should compile without importing framework modules.
node --import tsx src/main.tsStart 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.
Examples
A use case depending only on an interface
// application/checkout.ts - inside the use-case ringexport 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 ringexport 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.
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.
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.
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.
Common issues
Cannot find module 'express' imported from application/use-caseA framework import leaked into the core layer, violating the dependency rule.
Move the HTTP concern to the adapter ring and pass plain DTOs into the use case instead.
TypeError: orders.save is not a functionThe repository was never injected into the use case at the composition root.
Wire the concrete PostgresOrderRepository into CheckoutUseCase in main.ts, or inject a fake in tests.
Unit test hangs because it opens a real connection poolThe test constructed the production adapter instead of a fake port implementation.
Define an InMemoryOrderRepository in the test and inject it; keep infrastructure out of unit tests.
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.
Interview checkpoint
Hands-on lab
- 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
Quick quiz
Which direction must source-code dependencies point in Clean Architecture?
In Clean Architecture, your domain entities may import database driver types.
You want to unit-test a checkout use case without a database. What is the cleanest approach?
Cheat sheet
Entities / value objectsPure business modelUse casesOne business operation eachAdaptersRepos, controllers, presentersFrameworksExpress, Postgres, React driversComposition root (main)The only place that wires concretesNo framework imports in coreEnforce in CIPorts before implementationsDeclare the interface you needOne adapter per integrationSwap providers by replacing itDTOs across boundariesNo entity leakage to the edge