Layered Architecture
Presentation → Application → Domain → Infrastructure, with dependencies pointing one way only — a cheap, widely understood way to keep HTTP out of business rules and SQL out of controllers, until the layers become pass-through ceremony that adds files without adding decisions.
A codebase where HTTP parsing, business rules and SQL live in the same function cannot be tested without a server and a database, and cannot change its storage or its transport without rewriting its rules. Layers separate those concerns and fix the direction of dependency so that the rules do not know about the delivery mechanism or the storage.
Four layers, one direction
The classic stack has four layers. Presentation turns transport into calls: HTTP handlers, GraphQL resolvers, CLI commands, message consumers. Application orchestrates a use case — "place an order" — by loading domain objects, invoking their rules, and persisting the result; it owns transactions and coordination, not business rules. Domain holds the rules that would still be true if the company switched from a web app to a fax machine: an order cannot ship before it is paid, a discount cannot exceed the subtotal. Infrastructure talks to the world: database drivers, HTTP clients, queues, clocks, file systems.
The only rule that matters is the direction of dependency: each layer may import from the layer below it, never above. A controller may call a service; a service may call a repository; a repository must never call a controller. The domain sits at the bottom of the *conceptual* stack but at the top of the *importance* stack — everything depends on it, it depends on nothing. In the strict form each layer calls only its immediate neighbour; in the relaxed form presentation may skip application for trivial reads. Either is fine as long as arrows never point up.
Controller → service → repository
The everyday shape in a TypeScript service. The controller knows about Request and status codes and nothing else. The service knows the use case and the rule. The repository knows SQL. Each can be tested with the layer below faked: the controller with a stub service, the service with an in-memory repository, the repository against a real database in an integration test.
1// presentation: HTTP in, HTTP out — no rules here2export async function placeOrderHandler(req: Request, res: Response) {3 const result = await orderService.placeOrder(req.user.id, req.body.items)4 res.status(result.ok ? 201 : 422).json(result)5}6 7// application + domain rule: no HTTP, no SQL8export class OrderService {9 constructor(private readonly orders: OrderRepository) {}10 async placeOrder(userId: string, items: Item[]) {11 if (items.length === 0) return { ok: false, error: 'empty_order' } as const12 const order = Order.create(userId, items) // domain enforces invariants13 await this.orders.save(order)14 return { ok: true, id: order.id } as const15 }16}17 18// infrastructure: SQL only, no rules19export class PgOrderRepository implements OrderRepository {20 async save(order: Order) {21 await sql`INSERT INTO orders (id, user_id, total) VALUES (${order.id}, ${order.userId}, ${order.total})`22 }23}When layers stop paying for themselves
Layers are an abstraction, and an abstraction that does not absorb change is overhead. The tell-tale signs: pass-through services where UserService.getUser(id) is exactly this.repo.findById(id) and nothing else; an anemic domain where entities are bags of getters and every rule lives in a service, so the "domain layer" is a folder of DTOs; and a repository per table that mirrors the schema one-to-one and forces a use case to coordinate six repositories to save one aggregate. In each case the layer exists because the diagram said so, not because a decision lives there.
The opposite mistakes are worse because they invert the arrows. Business logic in controllers means the rule "a refund needs a manager over €500" exists only where HTTP requests arrive, so the nightly batch job that also issues refunds skips it. Domain depending on infrastructure — an Order entity that imports the Postgres client to save itself — means you cannot construct an Order in a test without a database, and swapping the database touches every entity. The fix for the second is the dependency inversion that Clean Architecture and Hexagonal Architecture (Ports & Adapters) make explicit: the domain declares an interface, infrastructure implements it.
A three-endpoint CRUD service does not need four layers. A handler that validates, runs one query and returns JSON is honest code. Add the application layer when two entry points share a use case; add a real domain layer when rules start to have rules. The measured requirement here is the count of places a rule is duplicated, not a diagram.
- Pass-through methods that add no decision are a signal the layer is premature; inline them.
- A rule that must hold for HTTP, batch and message consumers alike belongs in the domain, not the controller.
- Never let the domain import a driver: the moment it does, every test needs the real dependency.
Key points
- Presentation → Application → Domain → Infrastructure; imports point down only, and the domain imports nothing.
- Test each layer with the one below it faked: a stub service for the controller, an in-memory repository for the service.
- Pass-through services and anemic domains are layers without decisions — delete them until a decision appears.
- Business logic in a controller is a rule that only applies to one entry point; a domain that imports Postgres is a domain that cannot be tested.
- A small CRUD service can be honest with two layers; add a layer when two entry points share a use case.
Which way do the arrows point?
// UserService.ts — the anemic service method
async getUser(id: string) {
return this.userRepo.findById(id) // nothing else happens here
}
// UserRepository.ts
async findById(id: string) {
return this.db.query('select * from users where id = $1', [id])
}
// Three hops, one decision. The controller could have run the query.How data moves through it
One request or event, hop by hop.
- 1Client → Presentation: an HTTP request is parsed into typed input; auth and validation of shape happen here.
- 2Presentation → Application: a use-case method is called with plain values; no
Requestobject crosses this line. - 3Application → Domain: entities are loaded and their rules invoked; the domain returns a result or a rule violation.
- 4Application → Infrastructure: the repository persists the aggregate inside the transaction the application layer opened.
- 5Infrastructure → Presentation: the result bubbles back up and is rendered as a status code and JSON.
When to use — and when not
- A service with real business rules and more than one entry point (HTTP plus a queue consumer plus a cron job) that must all enforce them.
- Teams where junior engineers need an obvious place for each kind of code; "controller, service, repository" is learned in a day.
- Systems whose storage or transport is expected to change: swapping REST for gRPC touches only presentation.
- A thin CRUD API where every "service" would be a pass-through; the layers would be ceremony.
- Scripts, prototypes and internal tools measured in hundreds of lines.
- When the team cannot keep the arrows pointing down; a layered architecture with upward imports is worse than none, because it looks safe.
Tradeoffs
Almost free at runtime (in-process calls). The cost is files and indirection; the risk is layers that exist without decisions in them.
How it fails
- Business logic drifts into controllers; the batch job that bypasses HTTP skips the rule and issues refunds no manager approved.
- The domain imports the ORM; unit tests need a database and the suite takes 20 minutes, so people stop writing them.
- Every feature adds a controller, a service, a repository and three DTOs; a one-line change touches five files and reviewers stop reading.
- Repository-per-table leaks the schema upward: the use case does the joins the database should do, one query per row.
How it scales
- Runtime scaling is unaffected: layers are in-process calls, and the deployable unit is still one process copied behind a load balancer (Monolithic Architecture).
- It scales the *team* by giving each kind of change an obvious home; the limit is when several teams share the same layers and the folders become the boundary instead of the business modules (Modular Monolith).
- When a use case grows a dozen collaborators, the next step is not more layers but vertical slicing by feature with the layers inside each slice.
How it interacts with databases, queues, caches, APIs and external systems
- Database: accessed only through repositories in infrastructure; the domain never sees a connection or a query.
- Queue: a consumer is just another presentation adapter that calls the same application service the HTTP handler calls.
- Cache: belongs in infrastructure behind the repository interface, so the service does not know whether a read hit Redis.
- External APIs: wrapped by an infrastructure client; the application layer sees a method like
charge(amount), not an HTTP call.