Layered Architecture: A Guide to Responsibility Tiers
Learn layered architecture's responsibility tiers, dependency rules, code examples, and trade-offs for separating presentation, domain, and infrastructure.
Layered architecture separates request handling, use-case coordination, business rules, and technical integrations. This guide follows an order charge through those responsibilities, shows how dependency direction and repository ports protect the domain, and covers boundary enforcement, trade-offs, and production failure scenarios. Use the checklist to decide which tiers your application needs and where to add stronger rules.
Layered Architecture: A Practical Guide to Responsibility Tiers
Introduction
An order endpoint often starts as one handler: it reads HTTP input, checks the order rule, writes to the database, and builds a response. That is quick to ship, but a scheduled job that charges the same order can skip a rule that only exists in the controller.
// Before: transport, policy, and persistence are mixed together
async function chargeOrder(request: Request): Promise<Response> {
const body = await request.json();
if (body.cents <= 0) return new Response("Invalid charge", { status: 400 });
await database.orders.update(body.orderId, { charge: body.cents });
return Response.json({ ok: true });
}
A layered design keeps request parsing at the edge, puts the order rule in a shared domain/use-case path, and lets an infrastructure adapter handle storage. The existing example below follows that flow. This guide explains the responsibility tiers, dependency direction, and trade-offs, then shows how to keep those boundaries visible in code.
When to Use / When Not to Use
Use layers when the application has stable business responsibilities and more than one delivery or persistence concern. They help keep HTTP details out of policy code and make a codebase easier to teach. A conventional CRUD system can start with fewer layers and split responsibilities when real complexity appears.
Avoid imposing a full stack of abstractions on a tiny script, a static site, or a service with almost no domain behavior. Layers also do not solve distributed-system problems. If separate parts need independent deployment and scaling, evaluate service boundaries separately; see microservices versus monoliths.
Core concepts
Each tier owns a reason to change. Presentation translates transport details into application requests and formats responses. The application layer coordinates a use case, such as placing an order. The domain layer owns invariants that must hold regardless of whether the request came from HTTP, a job, or a test. Infrastructure implements technical capabilities such as persistence, messaging, and email.
The simplest dependency rule is top-down: outer layers call inward. The domain should not import a web framework or database driver. Some layered systems allow the application layer to depend on repository interfaces declared closer to the domain, with infrastructure providing implementations. That inversion preserves the business rule from persistence choices; it does not turn every interface into a virtue.
Mermaid diagram
flowchart TD
HTTP[Presentation: HTTP handlers] --> APP[Application: use cases]
APP --> DOMAIN[Domain: rules and entities]
INFRA[Infrastructure: database and external APIs] --> APP
INFRA --> DOMAIN
Infrastructure’s implementation relationship is shown toward the interfaces it fulfills. A diagram should make this dependency direction explicit; otherwise a layered picture can accidentally suggest that every request must flow through every layer.
Implementation / code example
Keep the use case focused on orchestration and place the invariant in the domain model. Here is a small TypeScript example:
// domain/order.ts
export class Order {
constructor(
readonly id: string,
private totalCents: number,
) {}
addCharge(cents: number): void {
if (cents <= 0) throw new Error("Charge must be positive");
this.totalCents += cents;
}
total(): number {
return this.totalCents;
}
}
// application/charge-order.ts
export interface OrderStore {
get(id: string): Promise<Order>;
save(order: Order): Promise<void>;
}
export async function chargeOrder(
id: string,
cents: number,
store: OrderStore,
): Promise<number> {
const order = await store.get(id);
order.addCharge(cents);
await store.save(order);
return order.total();
}
An HTTP handler validates the request shape, calls chargeOrder, and maps expected failures to status codes. A database adapter implements OrderStore. For a real charge, the transaction boundary and payment provider call need their own explicit design; the example is about ownership, not payment safety.
Enforcing Layer Boundaries
Write the intended imports down in the module structure, then make the rule easy to check in code review or a build. For example, a small TypeScript service might expose only these public modules:
src/
presentation/http/ -> application/
application/ -> domain/ and application/ports/
domain/ -> domain/ only
infrastructure/postgres/ -> application/ports/ and domain/
The arrows describe allowed imports. application/ports/order-store.ts can declare OrderStore; infrastructure/postgres/postgres-order-store.ts implements it. The application use case receives the port, so neither it nor the domain imports a database package. The composition root wires the implementation to the use case at startup.
A boundary check can be a review rule or a lint/build rule that rejects imports such as domain -> infrastructure and domain -> presentation. Keep it narrow: checking a few forbidden dependency directions is easier to maintain than a large list of allowed imports. If the repo does not have a dependency rule tool, start with code review and add automated enforcement after violations recur.
Common violations become easier to diagnose when the import tells the story:
| Violation | Why it causes trouble | Correction |
|---|---|---|
| A domain entity imports an ORM model or decorator | Persistence changes leak into business policy | Keep the domain type framework-free; map between it and storage records in the adapter |
| A controller updates an entity directly | Another entry point can skip the same rule | Route the operation through an application use case |
| Infrastructure imports a controller to reuse a response type | A technical adapter now depends on transport details | Put a stable contract near its consumer or map the type at the edge |
| Multiple layers import each other’s implementation modules | A change can create a cycle or require coordinated edits | Depend on a small inward-facing contract and wire concrete classes at the composition root |
Do not enforce a folder name as if it were a boundary. The rule should express which parts may depend on which other parts, and teams should review exceptions as architectural decisions.
Trade-off table
| Choice | Benefit | Cost |
|---|---|---|
| Separate responsibility tiers | Clear place for common changes | More files and navigation |
| Domain independent of frameworks | Rules are easier to test | Requires mapping at boundaries |
| Repository interfaces | Persistence can change behind a contract | Interfaces can become needless indirection |
| Strict one-way dependencies | Changes stay localized | Some cross-layer queries need deliberate designs |
Production failure scenarios + mitigations
Controllers contain business rules. A second entry point, such as a scheduled job, skips those rules. Move the invariant into the domain or use case, then test it without an HTTP server.
The repository causes N+1 queries. A clean layer boundary does not guarantee efficient SQL. Inspect query counts and latency, add a use-case-specific read method, or use a projection when the workload needs it.
A transaction ends too early. Saving an order and publishing an event in separate operations can leave inconsistent state after a crash. Use an outbox or another durable coordination mechanism where delivery guarantees require it; do not pretend a method call makes two systems atomic.
Observability checklist
- Record use-case name, outcome, duration, and a request or trace identifier.
- Track database query count and time at repository boundaries.
- Log actionable failure context without dumping credentials or personal data.
- Alert on user-visible symptoms such as error rate and latency, not folder-layer activity.
Security/compliance notes
Validate untrusted input at the presentation boundary, then enforce business authorization and invariants in the application/domain path so alternate entry points cannot bypass them. Infrastructure adapters should use least-privilege credentials and parameterized database access. Keep personal data out of routine logs and define retention for audit events separately from debug logs.
Common pitfalls / anti-patterns
- Creating a layer for every noun, even when it has no independent responsibility.
- Returning database entities directly to public API clients.
- Letting domain code import ORM annotations, HTTP status types, or infrastructure implementations. This is a dependency-direction violation even if the code still compiles.
- Treating repositories as generic CRUD wrappers when a use case needs a meaningful query.
- Building a “common” layer that becomes a dependency sink for unrelated code.
- Adding a shared utility to bypass a dependency cycle. Move the contract toward its consumer or give the shared behavior a clear owner instead.
Quick Recap Checklist
- Can each tier’s responsibility be explained in one sentence?
- Do business rules run regardless of the entry point?
- Do dependencies point toward policy, with adapters at the edge?
- Are transaction boundaries and query costs visible?
- Did the design avoid extra layers without a concrete reason?
Interview Questions
Further Reading
- Software Architecture Patterns Roadmap places layers alongside other structural choices.
- Separation of Concerns and Module Boundaries explains how to keep responsibilities and dependencies from spreading across modules.
- Martin Fowler’s Patterns of Enterprise Application Architecture catalog describes common application patterns and their trade-offs.
- Microsoft’s common web application architectures discusses layered and clean architecture in a .NET context.
Conclusion
Layered architecture is a practical way to assign responsibility and keep technical details from owning business rules. Its value comes from the dependency direction and the boundaries the team enforces. Start with the smallest useful set of tiers, then add structure when a change repeatedly lands in the wrong place.
Category
Related Posts
Clean and Onion Architecture: Keeping Policy at the Center
Compare clean and onion architecture, learn how concentric boundaries keep policy independent, and apply dependency rules with practical code and trade-offs.
Creational Patterns: Control Object Creation
Compare five creational patterns, see a compact TypeScript example, and choose the simplest fit for product families, construction, copying, or shared state.
Design Review: Trade-Offs and Maintainability
Review object designs for change, coupling, cohesion, tests, performance, operations, and security using a worked example and the practical checklist.