The one rule: dependencies point inward
Clean Architecture is often drawn as four concentric circles, and the drawing makes it look more complicated than it is. The whole idea is a single constraint on imports: source-code dependencies point inward, toward the domain. Outer layers may import inner layers. Inner layers may not import outer ones.
That is the dependency rule, and almost everything else is a consequence of it. The domain — the code that decides what an order is and when it may be cancelled — imports nothing from the framework, the database driver or the HTTP library. It is plain code that could run in a test process, a script or a different runtime entirely.
The reason to care is not purity for its own sake. It is that the things most likely to change are on the outside. Databases get upgraded or replaced, HTTP frameworks fall out of fashion, a payment provider is swapped. The rules of the business change too, but far more slowly and for different reasons. If the rules depend on the tools, every tool change drags the rules with it. If the dependency points inward, a tool change is confined to an adapter.
This is the same instinct as separating a library from its callers. You want the valuable, stable part to be usable and testable without hauling the volatile parts along.
The rule is about imports, not folders. Renaming services to domain and moving files around achieves nothing if the code inside still imports the ORM. The test is mechanical: open the innermost file and look at its import list. If it names a database, a framework or a vendor, the boundary is decorative.
The layers and what each one is allowed to know
The classic diagram has four rings. From the inside out:
Entities (or the domain). The business objects and their invariants: an Order must have at least one line, a Money value cannot mix currencies, an invoice cannot be paid twice. This layer is the most stable and the most valuable. It should be plain code with no imports from outside.
Use cases (the application layer). The things the system does: place an order, cancel a subscription, reset a password. A use case orchestrates entities to fulfil one intention, calls the ports it needs, and returns plain data. It contains the flow, not the rules.
Interface adapters. Controllers, presenters, repository implementations, serialisers. These translate between the outside world’s formats and the domain’s. A controller turns an HTTP request into a use-case input; a repository turns domain objects into rows and back.
Frameworks and drivers. Express, Postgres, Redis, the cloud SDK. The outermost ring, where the most detail lives and where the least design thought is needed. This layer is glue.
The rule is simple: an arrow may point inward, never outward. The use case may call OrderRepository, because that interface is defined in the domain. The domain may not call pg.Pool, because pg is an outer detail. When you feel the urge to import a database type into a use case, the answer is not a better import — it is a port.
Ports and adapters, the hexagonal view
Hexagonal architecture, also called ports and adapters, says the same thing with a picture that many people find easier to apply. The application is at the centre. Around it are ports: interfaces that declare what the application needs from the outside and what the outside can ask of it. Plugged into those ports are adapters: concrete implementations.
There are two directions of port:
- Driven ports (outbound) describe what the application needs: an
OrderRepository, aPaymentGateway, aClock. The domain owns the interface; infrastructure implements it. - Driving ports (inbound) describe what can be asked of the application: a use-case interface that a controller or a message consumer calls.
The important part is ownership. The interface lives next to the code that uses it, in the inner layer, not next to the implementation. This is what makes dependency inversion possible: the domain declares OrderRepository, and the Postgres class imports the domain to implement it. The arrow of imports points inward even though the arrow of control points outward.
A port should be shaped by the application’s needs, not by the database’s capabilities. If OrderRepository exposes findByCustomerAndStatusPaginated, the database schema has leaked into the application’s vocabulary. If it exposes findByCustomer, the application is describing what it needs and the adapter can decide how to satisfy it.
The composition root is what makes the picture work at runtime. Interfaces alone do not connect anything; somebody has to choose the implementation and hand it over. That choice happens once, at startup, in a single file. If you find yourself constructing a PostgresOrderRepository inside a use case, the inversion is nominal — the use case has simply moved its dependency from an import to a constructor call.
Dependency inversion in practice
Dependency inversion is the mechanism, not the goal. The goal is that the domain defines the contract and the infrastructure fulfils it.
Without inversion, the use case imports the repository class:
import { PostgresOrderRepository } from "../infrastructure/postgres-order-repository.js";
With inversion, the use case imports an interface, and the concrete class is passed in:
import type { OrderRepository } from "../domain/order-repository.js";
export class PlaceOrder {
constructor(private readonly orders: OrderRepository) {}
}
The concrete repository is chosen once, at startup, in a composition root. That is the only file that imports both the domain and the infrastructure. Everything else sees interfaces.
const orderRepository = new PostgresOrderRepository(pool);
const placeOrder = new PlaceOrder(orderRepository);
The practical payoff is immediate. In production, PlaceOrder gets a Postgres repository. In a unit test, it gets an in-memory fake. The use case code is identical in both, and it never had to know which one it received.
A worked example: placing an order
Follow one operation through the layers.
The controller receives POST /orders. It parses the body into a plain object, validates that the customer id is present and the lines are well-formed, and calls the use case. It does not touch the database and it does not build an Order itself.
router.post("/orders", async (req, res) => {
const result = await placeOrder.execute({
customerId: req.body.customerId,
lines: req.body.lines.map((line) => ({
sku: line.sku,
quantity: line.quantity,
unitPrice: Money.fromCents(line.unitPriceCents),
})),
});
res.status(201).json(result);
});
The use case builds the entity with Order.place, which enforces the invariant that an order needs at least one line. It then calls this.orders.save(order) — a port. It returns { orderId, totalCents }, plain data with no entity leaking out.
The adapter implements save. It opens a transaction, upserts the order row, replaces the lines and commits. It maps order.lines to rows and, in findById, maps rows back with Order.reconstitute. That mapping is the adapter’s job; the use case never sees a row.
Notice what the domain imported: Money, which is also domain code. Nothing else. Notice what the use case imported: the domain. Notice what the adapter imported: the domain and pg. The dependency arrows all point inward, and the mapping happens exactly at the boundary.
Clean Architecture is not the same as DDD
These two are often mentioned together and are not the same thing.
Domain-Driven Design is a set of ideas about modelling a complex business: entities with identity, value objects without it, aggregates as consistency boundaries, repositories for persistence, and a ubiquitous language shared by developers and domain experts. It is about what the domain model looks like.
Clean Architecture is about where the dependencies point. It says nothing about whether you need aggregates or domain events or a ubiquitous language.
You can use one without the other:
- A Clean Architecture app with a thin, anaemic domain still has the dependency direction, even if the rules are weak.
- A DDD app with TypeORM entities annotated in the domain has rich rules but the wrong dependencies.
They combine well, and the combination is what most people mean when they say “properly structured backend”. But if your domain is simple, adopting the dependency rule without full DDD is a perfectly good outcome. Use value objects like Money where they prevent real bugs; do not introduce an aggregate because a book said so.
Onion, hexagonal and clean: three names, one direction
Teams use these names as if they were competing patterns. They are three drawings of the same constraint, published years apart.
- Hexagonal architecture (Cockburn, 2005) puts the application at the centre with ports it owns and adapters around it. Its distinctive contribution is symmetry: inbound and outbound are both adapters.
- Onion architecture (Palermo, 2008) draws concentric layers and stresses that the domain model sits at the core and dependencies point inward.
- Clean Architecture (Martin, 2012) names four rings and states the dependency rule explicitly, adding a use-case layer between the entities and the adapters.
The vocabulary differs and the rule does not. In a code review, arguing about which diagram is correct wastes everyone’s time. What matters is whether the domain imports a framework and whether the interface lives next to its user. If those two answers are right, the pattern is being applied.
Reads do not need the ceremony
A frequent mistake is forcing queries through the same machinery as writes. A use case exists to protect invariants; a query has no invariants to protect. Wrapping a SELECT in an entity, a use case and a mapper adds files and mapping bugs for no benefit.
A pragmatic split is common: commands go through the domain and the ports, while queries go straight to a read model and return DTOs.
// queries/order-summary.ts
export async function getOrderSummary(pool: Pool, orderId: string) {
const { rows } = await pool.query(
`SELECT o.id, o.status, o.total_cents, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.id = $1`,
[orderId],
);
return rows[0] ?? null;
}
This query imports pg, and that is fine. It is an outer-layer adapter with no domain logic, so the dependency rule has nothing to protect. Keeping reads simple is not a compromise; it is the pattern applied honestly, because the value of a domain model is enforcing rules, and a read enforces nothing.
This is the seed of CQRS, and you can stop here. You do not need separate databases or event projections to let reads bypass the domain — just a clear line between operations that decide and operations that look.
Testing the domain without a database
The most concrete benefit of the dependency rule is test speed. Because the domain imports nothing, its tests need nothing.
A unit test for PlaceOrder constructs an in-memory fake repository and passes it in. The test asserts that the order was saved and that the total is correct. It runs in microseconds and needs no container, no migrations and no network.
const orders = new InMemoryOrderRepository();
const useCase = new PlaceOrder(orders);
const result = await useCase.execute({
customerId: "cust_1",
lines: [{ sku: "book", quantity: 2, unitPrice: Money.fromCents(1500) }],
});
expect(result.totalCents).toBe(3000);
Entity tests are even simpler. Money tests assert that adding mixed currencies throws and that multiply scales correctly. Order tests assert that placing an empty order throws and that cancelling twice is harmless. None of them mention a database.
The real database still needs testing, but the test is now about the adapter, not the rules: does PostgresOrderRepository.save write the rows, and does findById reconstruct the entity faithfully? That is one focused integration test per adapter, and when it fails you know the problem is in the mapping, not the business logic.
This split is the whole point. Without it, every test of a rule drags a database along, so tests get slow, flaky and rare. With it, the rules stay covered and the slow tests are few and targeted.
The price of indirection
Clean Architecture is not free, and pretending otherwise leads to teams applying it everywhere and resenting it.
More files. A single endpoint that used to be a handler and a query becomes a controller, a use case, a port, an adapter and a mapper. For a CRUD resource, that is five files where one would do, and the trace from request to query is longer.
Mapping in both directions. Rows become entities, entities become responses, and sometimes DTOs sit between. The mapping is boring, repetitive and easy to get subtly wrong — a missing field, a currency defaulted silently. It needs its own tests, and those tests are not testing business value.
A larger vocabulary. Ports, adapters, use cases, composition roots, DTOs. A new developer has to learn the layout before they can find anything, and a team that half-adopts it gets the worst of both worlds: indirection without consistent boundaries.
A false sense of decoupling. Importing an interface does not make you independent of the technology behind it. If your port exposes SQL semantics, or your domain relies on ON CONFLICT behaviour, you are coupled anyway. The interface is a seam, not a force field.
The honest framing is that this is an investment. It pays back when the domain is rich enough to test, when the infrastructure is likely to change, and when several people need clear boundaries. It does not pay back on a settings screen.
The payback is easiest to see in maintenance. When a rule changes, the change lands in one entity and one test. When a query needs an index, it lands in one adapter. When the team wants to try a new payment provider, they write a second adapter and change one line in the composition root. None of those changes ripple into the others, and that is the entire return on the extra files.
Where to draw the line
The pragmatic answer is not all-or-nothing. Draw the boundary where a rule exists, and leave the rest simple.
A useful heuristic: if an operation has a business rule that could be wrong in an interesting way, give it a use case and a domain object. Placing an order, applying a discount, cancelling a subscription — these have invariants worth protecting. If an operation is a straight read or write with no decisions, let it be a thin query, optionally behind a small repository, and do not wrap it in ceremony.
In the same system you can have both. A PlaceOrder use case with a rich Order aggregate and an in-memory fake sits next to a GetProductById that is a single query mapped to a DTO. Nobody is harmed by the asymmetry, and the codebase stays proportional to the problem.
Be equally pragmatic about the ORM. Many teams keep an ORM for reads and use hand-written repository adapters for the domain’s write path. Others keep raw SQL everywhere and accept that the adapter does the mapping. The rule is about direction, not about tooling: as long as the domain does not import the ORM, you are free to use whatever the adapter needs.
The most common middle ground is worth stating plainly. One domain package with real entities for the two or three concepts that carry rules. One use case per meaningful command. Repository interfaces only where the write path needs them. Everything else — reads, admin screens, reports — as thin handlers over SQL. That is not a compromise; it is the pattern scaled to the problem.
Structuring a project
The folder layout should make the dependency direction obvious at a glance. A common shape:
src/
domain/
order.ts
money.ts
order-repository.ts
application/
place-order.ts
cancel-order.ts
infrastructure/
postgres-order-repository.ts
stripe-payment-gateway.ts
interfaces/
http/
order-controller.ts
routes.ts
main.ts
The names matter less than the rule. domain imports nothing from the others. application imports domain. infrastructure and interfaces import both. main.ts wires them together and is the only file allowed to know every layer.
If you prefer vertical slices — a folder per feature with domain, application and infrastructure inside it — that works too and scales well when features are independent. What you lose is a single obvious place to look for cross-cutting rules; what you gain is that a feature is self-contained.
Enforce the direction with tooling rather than discipline. ESLint’s no-restricted-imports, dependency-cruiser or an import-boundary plugin can fail the build when domain imports pg. A rule that is only a convention is a rule that will be broken at 5 p.m. on a Friday.
Whatever layout you pick, keep the composition root explicit and small. A single main.ts that imports the concrete adapters and constructs the use cases is easy to read and easy to change. When wiring is spread across modules that each construct their own dependencies, nobody can say which database a use case is actually talking to, and the seam the architecture promised disappears.
What belongs in a use case
A use case is a single intention expressed as a class with one public method: PlaceOrder, CancelOrder, RefundPayment. Its execute method takes plain input, orchestrates the domain and the ports, and returns plain output. If you cannot name the intention as a verb and a noun, the use case is probably doing too much.
A use case should contain flow, not rules. It decides the order of steps — load, act, persist, publish. It does not decide what makes an order valid; that lives in the entity. The split matters because rules are reused across use cases while flows usually are not.
export class CancelOrder {
constructor(
private readonly orders: OrderRepository,
private readonly events: EventPublisher,
) {}
async execute(input: { orderId: string; reason: string }) {
const order = await this.orders.findById(input.orderId);
if (!order) throw new OrderNotFound(input.orderId);
order.cancel(); // the rule lives in the entity
await this.orders.save(order);
await this.events.publish("order.cancelled", { orderId: order.id });
}
}
What a use case should not do: build SQL, read req or res, know about HTTP status codes, send email directly, or import a framework. Each of those is either an outer-layer concern or belongs behind a port. If the use case imports express, the boundary has failed no matter what the folders are called.
Authorization is a genuine design question. Checking permissions in the use case keeps the rules in one place and makes them testable; checking them in middleware is less code and easier to forget. A workable answer is coarse checks at the edge and business-level authorization inside the use case, because “only the owner may cancel” is a rule, not a routing concern.
Transactions, side effects and the boundary
Transactions are an infrastructure concern, but their boundary is an application concern. The use case knows that a set of changes must commit together; it should not know that the mechanism is BEGIN and COMMIT in Postgres.
Two shapes work well. The simplest is to make each repository method transactional on its own, which is fine when a use case performs a single write. When a use case writes through several repositories and the changes must be atomic, introduce a unit of work port:
export interface UnitOfWork {
run<T>(work: (repos: Repositories) => Promise<T>): Promise<T>;
}
The adapter implements it with a connection and a transaction, and the use case wraps its work in unitOfWork.run. The domain still owns the interface, the adapter owns the SQL, and the atomicity is explicit in the application layer where it belongs.
Side effects follow the same rule. Sending an email, charging a card or publishing an event should be a port — EmailSender, PaymentGateway, EventPublisher — not a direct fetch call. That keeps the use case testable, because the fake records what would have been sent, and it keeps the choice of provider in the adapter.
Ordering side effects relative to the database is subtle. A use case that saves an order and then publishes an event has the dual-write problem: the process can die between the two. The reliable pattern is to write the event in the same transaction as the state — an outbox — and let a relay publish it. The use case asks an EventPublisher to record the event; the adapter decides whether that means an outbox row or a direct publish.
Anemic domain, leaky abstractions and framework types
Three failure modes look like Clean Architecture and are not.
An anaemic domain is a set of classes with only fields, getters and setters, while all the logic sits in services. The folders are correct, the dependency arrows are correct, and the domain is empty. It is not a disaster — a service layer over plain data is a legitimate design — but calling it a rich domain is self-deception, and it usually means invariants are enforced in several places and forgotten in one.
A leaky abstraction is a port shaped by its implementation. OrderRepository.upsertOnConflict mentions Postgres. PaymentGateway.chargeWithStripeToken mentions a vendor. A port should speak the application’s language: save, findById, charge. When a port leaks, swapping the adapter means changing the interface and every caller, which defeats the point of having a port at all.
Framework types in the domain are the most direct violation. An entity annotated with @Entity and @Column, or a use case whose input type is an Express Request, has imported an outer layer. It may compile and pass tests, but the dependency rule is broken, and the framework now decides when and how the domain is constructed. Keep decorators and request types in the outer layers and pass plain objects inward.
Testing strategy from the inside out
The dependency rule makes the test pyramid fall out naturally. Test from the inside, where tests are fast, and work outward only as far as needed.
Domain tests cover entities and value objects. They are pure unit tests with no I/O, and there should be many of them, because the rules are where bugs are most expensive. Money rejects mixed currencies; Order rejects an empty line list; cancelling twice is a no-op.
Use case tests cover the flow. Construct the use case with in-memory fakes for every port, call execute, and assert on the result and on what the fakes recorded. These catch missing steps, wrong ordering and forgotten saves, and they still run in microseconds.
Adapter tests cover the mapping. Run them against a real Postgres in a container, insert and read back, and assert that the entity round-trips faithfully. This is where you discover that status came back as a string or that a nullable column produced an undefined line. One focused test per adapter method is usually enough.
End-to-end tests cover the wiring: a real HTTP request through the controller, use case and database, asserting on the response. Keep these few. They are slow, they break for unrelated reasons, and their job is to prove the composition root is connected, not to re-test the rules.
The split means a failing rule test points at the domain, a failing flow test points at the use case, a failing round-trip test points at the adapter, and a failing end-to-end test points at the wiring. That diagnostic clarity is worth more than the raw number of tests.
When to introduce the boundary
You rarely get the boundaries right at the start, because you do not yet know where the rules are. A practical sequence is to begin with thin handlers and a data-access module, notice where logic accumulates, and extract a use case and a domain object at that point.
Signals that a boundary is worth introducing:
- The same rule is enforced in more than one handler.
- A test of a business rule needs a database or a running server.
- Changing a framework or an ORM would require touching the logic.
- A function mixes validation, persistence and external calls and is hard to name.
- Two developers keep colliding in the same file.
Signals that you should not bother:
- The operation is a straight read or write with no decisions.
- The rules are still changing daily and stabilising them is premature.
- The whole app is one person and a handful of endpoints.
Introduce the boundary where the pain is, not everywhere at once. A codebase with three well-drawn boundaries and a lot of simple code is healthier than one where every table has an aggregate and every call has a port.
Best practices
- Keep the dependency arrow pointing inward and let the domain import nothing.
- Define ports where they are used, in the inner layer, not where they are implemented.
- Shape ports around the application’s needs, not the database’s capabilities.
- Put concrete implementations in adapters and choose them only in the composition root.
- Map between rows, domain objects and DTOs at the boundaries; never let one layer’s types cross.
- Give a use case one intention and have it return plain data.
- Enforce invariants in the domain with factories and private constructors, not in controllers.
- Unit-test use cases with in-memory fakes and integration-test adapters separately.
- Keep the domain free of framework decorators, ORM annotations and HTTP types.
- Apply the pattern where rules exist and leave simple CRUD operations thin.
- Enforce import boundaries with a lint rule or dependency-cruiser in CI.
Common mistakes
- Annotating domain entities with ORM decorators and calling it Clean Architecture.
- Returning entities or database rows directly to controllers.
- Defining the repository interface next to the Postgres class instead of next to the use case.
- Writing an anaemic domain of getters and setters with the logic still in the service.
- Leaking SQL semantics into a port and believing the layers are decoupled.
- Abstracting every table and call, turning a CRUD app into ceremony.
- Putting the composition root everywhere, so nothing is actually inverted.
- Testing use cases against the real database and losing the speed that justified the split.
- Skipping the mapping and passing
anyacross a boundary. - Adopting full DDD, CQRS and event sourcing at once because a diagram suggested it.
Where to go next
Clean Architecture draws boundaries inside a single application. The Modular Monolith guide shows how to make those boundaries explicit between modules without paying the cost of a network, which is the right next step for most systems. When a boundary genuinely needs to become a deployable unit, the Microservices guide covers where service edges come from and what changes when a call becomes remote. If you want the ports to be cheap and self-documenting, the TypeScript guide covers interfaces and types, and PostgreSQL is the database most adapters are written against.