The dependency rule, without dogma
What the Clean Architecture dependency rule says, why the arrows point toward the domain, what goes in each layer, and the mistakes I see most often in TypeScript services.
Series · 1/3Clean Architecture without dogma
- 01The dependency rule, without dogma
- 02Ports and adapters in TypeScript
- 03Testing the domain and use cases without mocks
Swapping the database driver should not touch the pricing logic. Testing a discount rule should not start Postgres. If either happens in your service, the problem is almost never the framework. The dependencies point the wrong way. This series starts with the one Clean Architecture rule I consider non-negotiable, and leaves everything else for later.
What the rule says
- defDependency ruleaka The Dependency Rule
Source code dependencies point only inward, toward higher-level policies. No name declared in an outer circle can appear in the code of an inner circle.
The wording is Robert C. Martin’s. Note that it talks about source code dependencies, meaning import. It says nothing about how many layers you need or what the folders are called. That is the part worth taking seriously. The rest is convention.
The layers, from the inside out
I use four zones in almost every service.
- Domain. Entities, value objects, and business rules. Pure code, no I/O.
Order,Money, the rule that an empty order cannot be confirmed. - Application. Use cases that orchestrate the domain, and the ports those use cases need.
PlaceOrderand theOrderRepositoryinterface live here. - Infrastructure. Adapters that talk to the world. HTTP controllers, SQL repositories, queue clients, third-party SDKs.
- Composition root. The
main.tsthat instantiates everything and wires adapters to use cases. It is the only place that knows all of them.
The number of circles is not sacred. A small service can merge domain and application without losing anything. The direction of the arrows is not negotiable.
Why the arrows point inward
Volatile code depends on stable code
Business rules change when the business changes. The Postgres driver, the HTTP framework, and the payment provider SDK change for technical reasons, and they change more often. If the domain imports the driver, every driver upgrade is a change to the domain. If the driver imports the domain, the upgrade stays contained in one file.
Control flow is not dependency flow
At runtime, PlaceOrder calls the repository and the repository calls Postgres. Control flows outward. The trick is that the source code does not have to follow the same path. The use case declares the interface it needs, and the adapter implements it. That is dependency inversion, and it is what lets the import arrow point the opposite way from the call.
import { Pool } from 'pg';
import { Order, type OrderLine } from '../domain/order.js';
export class PlaceOrder {
constructor(private readonly pool: Pool) {}
async execute(id: string, customerId: string, lines: OrderLine[]): Promise<void> {
const order = Order.create(id, customerId, lines);
await this.pool.query(
'INSERT INTO orders (id, customer_id, lines) VALUES ($1, $2, $3)',
[order.id, order.customerId, JSON.stringify(order.lines)],
);
}
}import type { OrderRepository } from './ports/order-repository.js';
import { Order, type OrderLine } from '../domain/order.js';
export class PlaceOrder {
constructor(private readonly orders: OrderRepository) {}
async execute(id: string, customerId: string, lines: OrderLine[]): Promise<void> {
const order = Order.create(id, customerId, lines);
await this.orders.save(order);
}
}The coupled version is not wrong because it uses SQL. It is wrong because the use case cannot run without a real database, and because any schema change forces it to change.
Tests come for free
With the port in place, testing PlaceOrder means passing it an in-memory repository. No containers, no library mocks. The third article in the series is entirely about this.
What goes where
- src/
- domain/
- order.ts# entity and rules, no external imports
- money.ts
- application/
- place-order.ts# use case
- ports/
- order-repository.ts# interface the use case asks for
- payment-gateway.ts
- infrastructure/
- http/
- order-routes.ts# translates HTTP into use case input
- postgres/
- postgres-order-repository.ts
- http/
- main.ts# composition root
- domain/
The domain has to compile on its own. A quick way to check is to read its imports.
export type OrderLine = { sku: string; quantity: number; unitPriceCents: number };
export class Order {
private constructor(
readonly id: string,
readonly customerId: string,
readonly lines: readonly OrderLine[],
) {}
static create(id: string, customerId: string, lines: readonly OrderLine[]): Order {
if (lines.length === 0) throw new Error('An order needs at least one line');
return new Order(id, customerId, lines);
}
totalCents(): number {
return this.lines.reduce((sum, line) => sum + line.quantity * line.unitPriceCents, 0);
}
}There is not a single import. The highlighted lines are the business rule. They live in the domain because they do not depend on who calls them or where the order is stored.
The port interface lives on the side that uses it, not on the side that implements it.
import type { Order } from '../../domain/order.js';
export interface OrderRepository {
save(order: Order): Promise<void>;
findById(id: string): Promise<Order | undefined>;
}The methods describe what the use case needs. There is no query, no transaction, nothing that gives SQL away.
Common mistakes
Entities with ORM decorators
A TypeORM @Entity() or a @Column() on the Order class makes the domain import the persistence library. The rule breaks on the first line of the file. Separate the persistence model from the domain model and map between them in the adapter. It costs one more file per aggregate and saves you from migrations that break business rules.
Use cases that speak HTTP
If PlaceOrder returns a 404 or throws an HttpException, the application knows about the transport. Return a domain result, for example { kind: 'not-found' }, and let the controller translate it into a status code. The same use case is then ready for a queue consumer or a CLI.
An interface for every class
The rule does not ask for an interface on everything. It asks for ports at the edges with I/O or with things that change for technical reasons. A pure tax calculation function does not need ITaxCalculator. If an interface has one implementation and no reason to have another, it is noise.
The shared folder
A shared or utils folder that every layer imports becomes the shortcut around the rule. One day someone adds a helper there that imports the Redis client, and the domain is coupled without anyone touching a domain file.
Trusting the folder structure
Folders enforce nothing. A linter does. ESLint’s no-restricted-imports is enough to start.
export default [
{
files: ['src/domain/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{ group: ['**/application/**', '**/infrastructure/**'], message: 'The domain does not know outer layers.' },
{ group: ['pg', 'express', 'fastify'], message: 'The domain does not depend on frameworks.' },
],
}],
},
},
];Add a similar block for src/application/** that forbids infrastructure. If the project grows, dependency-cruiser lets you express rules between folders in more detail.
Without dogma
The rule is about volatility and I/O, not purity. Importing date-fns or a decimal library from the domain is fine. Those libraries are stable and touch neither the network nor the disk. Validating a request body with Zod in the controller is also fine, because that is infrastructure. What has to stay out of the domain is everything that changes for reasons the business does not control.
You also do not need to apply the rule with the same intensity across the codebase. A read-only endpoint that builds a report can read from the database directly in the adapter, without going through entities. Separating writes from reads is a reasonable decision when the read side has no rules.
A use case needs to send an email. Where does the EmailSender interface go?
The port belongs to its consumer. The use case defines what it needs, and the SendGrid adapter implements it by importing that interface. That way the arrow points inward.
Summary
- The rule is about imports. Inner code never names outer code.
- Control can flow outward while dependencies point inward. Ports make that possible.
- The domain reads without framework imports. If it does not, something leaked.
- Interfaces only at the edges with I/O or with volatile things.
- A linter enforces the rule. Folders only suggest it.
The next article builds the OrderRepository and PaymentGateway ports, with an in-memory adapter, a Postgres adapter, and the composition root that wires them.