Guide
Clean architecture explained
Clean architecture is a way of structuring applications so business rules sit at the center and everything else — databases, web frameworks, UI toolkits, message brokers — wraps around them as replaceable details. Robert C. Martin ("Uncle Bob") popularized the name, but the idea is older: your domain should not import Express, Django, or PostgreSQL; those tools should depend on your abstractions. The signature rule is simple: source-code dependencies point inward only. Outer layers know about inner layers; inner layers never know about HTTP status codes or ORM annotations. This guide walks through the four concentric rings (entities, use cases, interface adapters, frameworks), how clean architecture relates to hexagonal and onion models, practical folder layouts, why tests get faster, common over-engineering traps, and a checklist for adopting layers without turning a CRUD app into a cathedral.
Why frameworks should not own your business logic
Most tutorials teach "Rails way" or "NestJS way" first: controllers call services that call repositories that call the ORM. That works until you need to swap Postgres for DynamoDB, run the same pricing rules from a CLI and a queue worker, or unit-test a refund policy without spinning up Docker. When business logic imports framework types, every change ripples outward and tests require heavy fixtures.
Clean architecture inverts the typical dependency graph. A
RefundOrder use case declares it needs a PaymentGateway
interface; an adapter in the outer ring implements that interface with Stripe. Swap
Stripe for Adyen by writing a new adapter — the use case file stays untouched. That
separation is what
dependency injection
and
adapter/strategy patterns
enable in practice.
The dependency rule
Picture concentric circles. The innermost ring holds entities — pure
business objects and invariants (a Money value object that rejects
negative amounts, an Order that cannot ship before payment clears). The
next ring holds use cases (application services) — orchestration
scripts that load entities, enforce a single application-specific workflow, and persist
results through interfaces. The third ring is interface adapters —
controllers, presenters, gateways, repository implementations that translate between
the outside world and use-case inputs/outputs. The outermost ring is
frameworks and drivers — Express routes, SQL drivers, React components,
Kafka clients.
The dependency rule: nothing in an inner circle may reference a name defined in
an outer circle. Entities cannot import DTOs. Use cases cannot import
Request from Express. Violations are patched with
dependency inversion — inner layers define interfaces; outer layers
implement them. Data crossing a boundary uses simple structures (input/output models)
so inner code never sees JSON field names tied to a particular API version.
The four layers in detail
Entities (enterprise business rules)
Entities encode rules that would exist even if you had no software — interest accrual, inventory non-negativity, subscription state machines. They should be framework-agnostic plain objects (or language equivalents). Keep them small; not every database row needs to be an entity class. Value objects from domain-driven design often live here.
Use cases (application business rules)
A use case represents one thing the application does: "Place order," "Approve expense,"
"Generate monthly statement." It coordinates entities and calls gateway interfaces
(OrderRepository, EmailNotifier) without knowing implementations.
Use cases are the sweet spot for
fast unit tests
— inject fakes, assert outputs, no network.
Interface adapters
Controllers parse HTTP, validate auth tokens, map request bodies to use-case input structs, and map results to HTTP responses. Presenters format data for a specific UI. Repository classes implement persistence interfaces with SQL or document queries. Gateways wrap third-party APIs. Adapters are allowed to know about both frameworks and use-case contracts; they translate between them.
Frameworks and drivers
The outer shell: web server bootstrap, ORM configuration, dependency-injection
container wiring, migration runners, build tooling. This is where
main() lives — the composition root that binds interfaces to concrete
classes. Framework churn is isolated here; swapping Fastify for Hono should not touch
entity code.
Clean, hexagonal, onion — how they relate
These names describe the same family of ideas with different metaphors:
| Model | Metaphor | Emphasis |
|---|---|---|
| Clean architecture | Concentric circles | Explicit layers (entities → use cases → adapters → frameworks) and the dependency rule |
| Hexagonal (ports & adapters) | Hexagon with plugs | Application core with inbound/outbound ports; adapters plug in from any side |
| Onion architecture | Layered onion | Domain model center; application services; infrastructure on the outside |
| Traditional layered (3-tier) | UI → BL → DAL stack | Top-down dependencies; business layer often leaks DB concerns inward |
In practice, teams mix terms. A "hexagonal" service might use clean-architecture folder names. What matters is the invariant: domain and application rules do not depend on delivery or persistence mechanisms. Pick one vocabulary per repo and document where controllers, use cases, and repositories live.
Folder layout that teams actually ship
There is no single canonical tree, but feature-based layouts age better than
technology-based ones (/controllers, /models,
/views alone tell you nothing about the product). A pragmatic structure:
src/
domain/ # entities, value objects, domain services
application/ # use cases, input/output ports (interfaces)
infrastructure/ # ORM repos, HTTP clients, queue publishers
interfaces/ # HTTP controllers, CLI commands, GraphQL resolvers
main.ts # composition root / DI wiring
For larger systems, slice by bounded context instead of layer globally —
billing/domain, billing/application — so
service
boundaries align with folder boundaries. A modular monolith can enforce import
rules with lint plugins (e.g., "domain cannot import infrastructure") so violations
fail CI instead of accumulating silently.
Testing and change isolation
Clean architecture pays off most in the test pyramid's base. Use-case tests run in
milliseconds: construct entities, call RefundOrder.execute() with an
in-memory repository, assert balance and emitted domain events. Integration tests cover
adapters (does the SQL repository honor the interface contract?). End-to-end tests stay
few — they validate wiring, not every branch.
When requirements change — new tax rule, different idempotency key — you edit entities or one use case. When infrastructure changes — migrate ORM, add Redis cache — you edit adapters. That separation shortens code review scope and reduces regression risk during refactors that would otherwise touch controller files mixed with SQL strings.
When clean architecture helps — and when it hurts
| Situation | Recommendation |
|---|---|
| Complex domain rules, multiple delivery channels (API + workers + CLI) | Adopt — use cases amortize duplication |
| Long-lived product, frequent infra swaps | Adopt — adapters absorb churn |
| Prototype, hackathon, throwaway demo | Skip — framework-first is faster |
| Simple CRUD admin with one UI and stable stack | Light layers — repository interface maybe; skip ceremony |
| Microservice with a single endpoint and no shared domain | Thin service — full clean rings are often overkill |
The failure mode is architecture astronautics: six interfaces and four DTO mappers for "get user by ID." Start with the dependency rule on new features; extract use cases when controllers grow past ~40 lines or when the same logic is copy-pasted into a cron job.
Common anti-patterns
- Anemic domain model — entities are data bags; all logic sits in "service" god classes. Push invariants back into entities where they belong.
- Leaky repositories — repository interfaces expose ORM types or
QueryBuildermethods. Return domain objects; hide SQL shapes. - DTO explosion — identical structs copied across layers. Share simple input/output types at use-case boundaries only; do not map entity → DTO → entity for every field.
- Framework in use cases — importing
@Transactional, logging decorators, or HTTP exceptions inside application services. Keep use cases pure; let adapters handle cross-cutting concerns. - Skipping the composition root — use cases call
new PostgresOrderRepo()directly. Wire dependencies inmainor the DI container.
Production checklist
- Dependency rule documented — inner layers have zero imports from infrastructure or framework packages.
- Use cases named after user-visible actions, one primary path per class or module.
- Ports (interfaces) defined by the application layer; adapters implement them in infrastructure.
- Composition root is the only place that instantiates concrete adapters and passes them inward.
- Lint or arch-unit tests enforce forbidden imports (domain → infrastructure).
- Unit tests cover use cases with fakes; no database required for business-rule regressions.
- API versioning handled in controllers — not by branching inside entities.
- CI pipeline runs fast use-case tests on every push before slower integration suites.
Key takeaways
- Clean architecture puts business rules at the center and treats frameworks, UI, and databases as outer, replaceable details.
- The dependency rule — inward-only source dependencies — is the non-negotiable invariant; use interfaces to invert dependencies.
- Use cases orchestrate workflows and are the primary unit of application logic and fast testing.
- Hexagonal and onion models share the same goal; pick one vocabulary and enforce it with folders plus lint rules.
- Apply proportionally — full layering for complex domains; thin structure for simple CRUD until pain appears.
Related reading
- Domain-driven design explained — bounded contexts, aggregates, and ubiquitous language inside the domain ring
- Dependency injection explained — wiring ports to adapters at the composition root
- Software design patterns explained — adapter, repository, and strategy patterns clean architecture relies on
- Microservices architecture explained — when to split bounded contexts into separate deployables