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.

published: reading time: 10 min read author: GeekWorkBench
Quick Summary

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

1. What does a layer represent?
A responsibility boundary, such as HTTP translation, use-case coordination, business policy, or technical integration. A directory alone does not create a useful layer.
2. Should the domain layer know about the database?
Usually not. Keep storage mechanics in infrastructure. The application can depend on a small persistence contract when that helps isolate business behavior from a particular database.
3. Does layered architecture require every request to visit every layer?
No. Layers describe responsibility and dependency direction. A simple query may use a read path suited to its needs, provided the team keeps ownership and security rules clear.
4. Which direction should source-code dependencies point?
Usually toward business policy: presentation and infrastructure depend on application or domain contracts. The domain should not import HTTP, database, or framework details.
5. How can infrastructure implement a repository without the application depending on infrastructure?
The application declares a small repository port. Infrastructure implements that contract, and a composition root wires the implementation into the use case.
6. Where should an invariant live if HTTP and a scheduled job both change an order?
Put the invariant in the domain model or application use case that both entry points call. A controller-only check can be skipped by the job.
7. What is a warning sign that a layer boundary is only cosmetic?
Business code imports framework or persistence types, or changes routinely require edits across every layer. Folders alone do not prevent either problem.
8. How would you enforce dependency direction in a codebase?
Start with explicit allowed directions and review them. Add a lint or dependency-check rule for forbidden imports, especially inward-to-outward dependencies and cycles.
9. Does a clean repository abstraction guarantee efficient database access?
No. The adapter can still issue N+1 queries or fetch too much data. Measure query count and latency, then shape read methods around actual use cases.
10. How can saving data and publishing an event fail in a layered service?
If they are separate operations, a crash can occur after the database commit but before publication. An outbox records the event in the same transaction for later delivery.

Further Reading

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.

#software architecture #clean architecture #onion architecture

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.

#object-oriented-design #design-patterns #creational-patterns

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.

#object-oriented-design #design-review #maintainability