Clean Architecture for Developers Who Have Outgrown Fat Controllers
Translate clean architecture layers into testable use cases, ports, and incremental refactors on real codebases.
“Clean Architecture” books and diagrams look elegant until your sprint board is full of legacy controllers and a three-line fix touches six packages. The idea still matters: keep business rules independent from frameworks, databases, and UI so you can test logic quickly and swap infrastructure without rewriting the core.
This guide translates Robert Martin’s layers into habits that work on real teams—without pretending every microservice needs a ports-and-adapters seminar.
The problem clean architecture tries to solve
Most web apps start as “thin CRUD.” Over years:
- SQL queries leak into HTTP handlers
- Framework decorators become the only place validation lives
- Changing email providers breaks checkout because logic lived in a Rails callback
Clean architecture responds with dependency direction: inner circles define interfaces; outer circles implement them. The domain never imports Express, Django, or Prisma—those adapters import the domain.
The four rings (practical names)
Think of concentric responsibilities:
- Entities / domain model — business objects and invariants (e.g.,
Ordercannot ship without payment) - Use cases / application services — orchestration: “Place order,” “Refund line item”
- Interface adapters — controllers, presenters, gateways translating HTTP/CLI to use case DTOs
- Frameworks & drivers — database, web server, third-party APIs
Dependencies point inward. A use case depends on an interface PaymentGateway, not on Stripe’s SDK type.
A tiny example (TypeScript-ish)
Domain:
export class Money {
constructor(readonly cents: number) {
if (cents < 0) throw new Error("negative");
}
}
export interface PaymentGateway {
charge(orderId: string, amount: Money): Promise<{ receiptId: string }>;
}
Use case:
export class PlaceOrder {
constructor(
private readonly payments: PaymentGateway,
private readonly orders: OrderRepository,
) {}
async execute(input: { orderId: string; total: Money }) {
const order = await this.orders.get(input.orderId);
order.markAwaitingPayment();
const receipt = await this.payments.charge(order.id, input.total);
order.markPaid(receipt.receiptId);
await this.orders.save(order);
return order;
}
}
Infrastructure implements PaymentGateway with Stripe; tests use a fake that records calls.
Where teams get stuck
Anemic domain models
If entities are only data bags and all logic sits in “service classes,” you gain folder structure without true isolation. Push invariants back into entities when they are always true (Money, EmailAddress).
Over-abstracting early
A five-person product does not need twelve interfaces per bounded context. Start with one use case per major user story and extract ports when you have a second implementation or painful tests.
Ignoring delivery mechanisms
Clean architecture is not anti-framework. Your Express route should be thin:
app.post("/orders/:id/pay", async (req, res) => {
const result = await placeOrder.execute({ ... });
res.json(OrderPresenter.toJSON(result));
});
Fat controllers signal missing use cases.
Testing payoff
Use cases tested with in-memory repositories run in milliseconds. That is the ROI: regression tests for business rules without booting Docker.
Integration tests still matter at the adapter boundary—one suite against real Postgres, one against the payment sandbox.
Mapping to hexagonal and onion variants
Hexagonal architecture emphasizes ports and adapters; onion layers mirror the same dependency rule. Names differ; the test is the same: can you compile the domain without your web framework on the classpath?
Working with legacy code
You rarely greenfield clean architecture. A incremental path:
- Identify a high-churn use case (pricing, discounts, permissions).
- Extract a use case class and hide SQL behind a repository interface.
- Write characterization tests before moving code.
- Repeat outward; do not boil the ocean.
Strangler fig patterns let new boundaries coexist with old modules.
Microservices caution
Splitting services along clean layers per service is healthy. Splitting every use case into its own deployable is not. Share nothing until you need independent scaling or team ownership—and then duplicate domain carefully or publish versioned contracts.
Documentation that helps
For each use case, maintain a short context note: triggers, preconditions, failure modes, and emitted events. Future you will not read 200-line sequence diagrams.
FAQ
Is clean architecture the same as DDD?
Related but not identical. DDD focuses on modeling language and aggregates; clean architecture focuses on dependency structure. They complement each other.
Does this work with serverless handlers?
Yes—treat each Lambda handler as an adapter calling the same use cases. Watch cold-start size if you import heavy SDKs in the domain layer (you should not).
What about CQRS and event sourcing?
Optional patterns for read/write separation or audit trails—not mandatory for clean architecture.
When is it overkill?
Prototypes and throwaway demos. Once multiple developers touch billing or compliance logic, structure pays rent.
Collaboration with product and design
Clean boundaries help non-engineers too. When product asks for “a small tweak” to refund rules, a well-placed use case localizes change. Presenters map domain objects to API shapes the mobile app expects—without leaking ORM entities that rename columns every sprint.
Agree on ubiquitous language in tickets: use the same words in Jira, code (PlaceOrder), and support docs. That alignment is half of DDD and costs nothing in tooling.
Performance and boundaries
Layers add indirection. For most business apps, the database dominates latency—not an extra function call. Where hot paths matter (pricing engines, bidding), profile before collapsing layers blindly. Sometimes a carefully bounded module stays fast without importing the web stack.
Cache at use-case boundaries with explicit invalidation rules rather than sprinkling Redis in repositories unless you have a standard pattern.
Choosing boundaries in monoliths vs services
In a monolith, packages or modules can represent layers (domain/, application/, infrastructure/) without network overhead. Enforce dependency rules with lint tools or architectural tests (e.g., “domain must not import express”) so structure does not decay.
When you split services, duplicate stable domain concepts carefully—two definitions of Money that diverge cause reconciliation bugs. Shared kernel libraries are acceptable when versioned semver and kept tiny.
Anti-patterns to flag in code review
- HTTP status codes decided inside SQL repositories
- DTOs that mirror database tables 1:1 with no domain meaning
- “God services” thousands of lines long with no use case names
- Tests that only hit controllers with every dependency mocked—signal missing use cases
Reviews that ask “where is the use case?” gently steer teams without architecture theater.
Reading list (optional depth)
Martin’s Clean Architecture and Evans’ Domain-Driven Design complement each other—read chapters on entities and application services first, then aggregates if your domain is transaction-heavy. Vendor-neutral workshops on architectural fitness functions help automate dependency rules so reviews scale with team size.
One-sprint starter plan
If you want momentum this week: pick one endpoint that encodes business rules, extract a use case with a single public method, move persistence behind an interface, and add three unit tests that would have been painful before. Demo the tests in standup—not the diagram. Teams adopt structure when they feel faster, not when slides say they should.
Also celebrate deleting dead abstractions, too. If an interface only ever had one implementation for two years, inline it and move on—clean architecture is about clarity, not maximum indirection or ceremony.
Takeaway
Clean architecture is a discipline about where code may depend, not a trophy diagram. Keep business rules testable and framework-agnostic in the center; let databases and HTTP be replaceable details on the outside. Done incrementally, it reduces the fear of changing the parts of the system that actually make money.
Comments
Loading comments…