Engineering Notes
Home/Architecture/Architecture/Layered Architecture
Architecture · Architecture

Layered Architecture

Split an application into horizontal layers where each layer only talks to the one below. See a request flow down and back, the single rule that makes layering work, and what layering does not solve.

🧩 Architecture● Beginner⏱ 7 min read▶ 2 interactive🛠️ 2 exercises🎤 5 interview Q&As

🎯 What you'll learn

  • The core idea: presentation, application, domain, data
  • The one rule: lower layers don't know upper ones
  • Strict vs relaxed layering
  • What layering doesn't solve (and why domain→data dependency hurts)

1The Core Idea

Layered architecture organizes code into horizontal layers, each with one job, where a layer may only call the layer(s) directly below it:

text
┌─────────────────────────────┐
│  Presentation (controllers)   │  ← handles HTTP, validates input shape
├─────────────────────────────┤
│  Business logic (services)    │  ← the actual rules of the application
├─────────────────────────────┤
│  Data access (repositories)   │  ← reads/writes the database
├─────────────────────────────┤
│  Database                     │
└─────────────────────────────┘

A request flows top-down: Controller → Service → Repository → Database. A response flows back up the same path. This is the most common architecture in the industry — most frameworks (Express + a service layer, Spring MVC, Rails) nudge you into it by default.

2A Minimal Example

ts
// data access layer
class UserRepository {
  async findById(id: string): Promise<{ id: string; email: string; passwordHash: string } | null> {
    // pretend SQL query
    return { id, email: "a@example.com", passwordHash: "hashed" };
  }
}

// business logic layer
class UserService {
  constructor(private repo: UserRepository) {}

  async getPublicProfile(id: string) {
    const user = await this.repo.findById(id);
    if (!user) throw new Error("not found");
    // business rule: never expose passwordHash outward
    return { id: user.id, email: user.email };
  }
}

// presentation layer
class UserController {
  constructor(private service: UserService) {}

  async handleGetProfile(req: { params: { id: string } }) {
    const profile = await this.service.getPublicProfile(req.params.id);
    return { status: 200, body: profile };
  }
}

Each layer only knows about the layer directly beneath it: UserController never touches SQL, UserRepository never knows what "public profile" means.

Presentation calls Business, Business calls Data Access, Data Access calls the Database — strictly top-down, one direction
Presentation calls Business, Business calls Data Access, Data Access calls the Database — strictly top-down, one direction

3The One Rule That Makes It Work: Layers Below Don't Know About Layers Above

UserRepository must never import or reference UserController or UserService. This is what keeps the dependency graph acyclic and each layer independently understandable — you can read UserRepository in isolation and know everything it depends on (just the database driver), without needing to know who calls it or why.

ts
// VIOLATION: repository reaching upward
class UserRepository {
  constructor(private service: UserService) {} // ❌ data layer depending on business layer
}

If this creeps in, the layers stop being layers — you get a dependency cycle, and "just read the repository to understand what it does" stops being true, because now you also need to understand the service to understand the repository.

4Strict vs Relaxed Layering

  • Strict layering: a layer may only call the layer immediately below it (Controller → Service → Repository, never Controller → Repository directly).
  • Relaxed layering: a layer may call any layer below it, skipping intermediate ones when there's no logic to add.
ts
// Relaxed: controller calls repository directly for a trivial read with no business rules
class HealthController {
  constructor(private repo: UserRepository) {} // skips a UserService that would add nothing
  async handleHealthCheck() {
    await this.repo.findById("healthcheck-user");
    return { status: 200 };
  }
}

Relaxed layering trades a small amount of consistency for avoiding pointless pass-through service methods that just forward one call — a real cost when a codebase has hundreds of such pass-throughs. Strict layering trades a bit of ceremony for a codebase where "where do I look for X" always has exactly one predictable answer regardless of how trivial X is. Most real codebases mix these deliberately, not by accident — pick one policy per project and be explicit about it, rather than letting it be decided inconsistently PR by PR.

5What Layered Architecture Does Not Solve

Layered architecture organizes where code lives, but says nothing about what the business layer is allowed to depend on. In the naive version above, UserService still typically ends up importing a specific ORM's types, a specific web framework's request/response shapes, or specific error classes from a specific library — because "business logic" is just "the middle layer," not "a framework-independent core." That's precisely the gap Clean/Hexagonal architecture (next topic) closes: it keeps the same top-down call direction but inverts the dependency direction so business logic doesn't import infrastructure at all.

Under the HoodWhy "Business Logic Depends on Nothing Below It" Is Harder Than It Sounds▾

The naive reading of layered architecture is "Service imports Repository, so Service depends on Repository" — true at the call level, but the actual coupling usually runs deeper than a single import statement, in three specific ways that cause real pain months later:

1. Leaky return types. If UserRepository.findById returns whatever shape the ORM gives back (say, a Sequelize model instance with .save(), .reload(), lazy-loaded associations, and ORM-specific metadata attached), then UserService is coupled to the ORM's runtime behavior even though it never wrote import { Sequelize }. Swapping the ORM later means finding every place in the service layer that relies on one of those ORM-specific behaviors — which nothing marks explicitly, because the coupling is structural, not textual.

ts
// Leaky: repository returns the raw ORM model
async findById(id: string): Promise<UserModel> {   // UserModel is a Sequelize class
  return UserModel.findByPk(id);
}
// Service can now accidentally call user.reload() or rely on lazy-loaded
// associations — behavior that has nothing to do with "get a user by id"
// and will silently break the day the ORM is swapped.
ts
// Not leaky: repository maps to a plain data shape the service actually needs
interface UserRecord { id: string; email: string; passwordHash: string }
async findById(id: string): Promise<UserRecord | null> {
  const row = await UserModel.findByPk(id);
  return row ? { id: row.id, email: row.email, passwordHash: row.passwordHash } : null;
}

The second version costs one extra mapping line per method and buys back the ability to swap the ORM by rewriting only the repository, never the service.

2. Exception leakage. A raw database driver throws driver-specific errors (a Postgres unique-constraint violation error, a Mongo duplicate-key error). If the service layer doesn't catch and translate these at the repository boundary, the business layer ends up with catch (e) { if (e.code === '23505') ... } — Postgres error codes inside "business logic," which is now silently coupled to a specific database engine despite never importing its driver.

3. Testing cost as the real signal of a violation. The concrete, measurable cost of these two leaks: writing a unit test for UserService.getPublicProfile without a real (or even in-memory) implementation of every ORM behavior the service accidentally relies on becomes impossible. If a service's tests require standing up a real database, that's not a testing inconvenience — it's evidence the "layer" boundary is fictional, because the thing you'd need to fake (UserRepository) isn't a clean enough seam to fake against. This is the exact problem the Repository Pattern topic and Dependency Inversion topic solve directly.

⚠️ Common Pitfalls

  1. Repository returning raw ORM/driver objects instead of plain data — silently couples every layer above it to that specific library's behavior, as shown in section 6.
  2. Skipping the service layer "just this once" and letting it become the norm — a few controller-calls-repository shortcuts are fine as an explicit relaxed-layering policy (section 4), but uncontrolled creep means business rules end up scattered across controllers with no single place to find them.
  3. Fat controllers — putting business rules (validation beyond input shape, authorization checks, calculations) directly in the controller because "it's just a few lines" — these rules then can't be reused or unit-tested independently of an HTTP request/response.
  4. Anemic services that just pass through — a service layer that does nothing but forward every call to the repository unchanged adds a layer of indirection with zero benefit; if this is true for an entire module, relaxed layering (section 4) is the honest choice.
  5. Cross-layer shortcuts that go the wrong direction — a repository importing a service to "trigger a side effect" (e.g., send an email after saving) creates a cycle; side effects that need to happen after persistence belong in the service, orchestrated after the repository call returns.

📋 Quick Reference

text
Layers: Presentation → Business logic → Data access → Database   (calls flow top-down)
Rule: a layer never calls back UP into a layer above it
Strict layering: skip nothing         Relaxed layering: skip layers that would add nothing
The real coupling risk isn't the import statement — it's leaked ORM return types and leaked driver exceptions
Test signal: if testing the service needs a real DB, the repository boundary isn't a real seam yet
Layered ≠ Clean/Hexagonal: layered controls call direction; Clean also controls *dependency* direction

🧠 Key takeaways

  • Layers group code by technical responsibility: presentation → application → domain → data.
  • The rule: dependencies point downward only; lower layers never import upper ones.
  • Strict layering (only the next layer) vs relaxed (skip layers): be explicit.
  • Layering alone still lets the domain depend on the database, which is what clean architecture inverts.

🎤 Interview Q&A

Questions interviewers ask about this topic. Answer out loud first, then open a card to compare.

Q1Describe the classic layered architecture.core▾

Presentation (UI/controllers) → Business/application logic → Data access (repositories) → Database. Each layer only depends on the layer below. It separates concerns and is easy to understand, which is why it's the default for many applications.

Follow-ups interviewers ask
  • Strict vs relaxed layering? Strict: only call the next layer down; relaxed: may skip layers, which is simpler but couples more.
Q2What are the drawbacks of layered architecture?core▾

Business logic tends to depend on data access (and so on the database), changes often cut through all layers, layers can become pass-throughs with no logic ('sinkhole' anti-pattern), and organizing by technical layer scatters a feature across folders.

Follow-ups interviewers ask
  • How does clean architecture address this? It inverts the dependency so the domain doesn't depend on infrastructure.
Q3What belongs in the business layer vs the presentation layer?core▾

Business layer: rules, validation of domain invariants, workflows and transactions. Presentation: HTTP parsing, input format validation, authentication context, mapping DTOs, response codes. Controllers should be thin.

Follow-ups interviewers ask
  • Sign of a fat controller? Business decisions and DB queries inside controller methods.
Q4Layers vs tiers?senior▾

Layers are logical separations in code; tiers are physical deployment units (browser, app server, database server). A three-layer app can run as one tier or many.

Follow-ups interviewers ask
  • Why does it matter? Crossing a tier adds network latency and failure modes; crossing a layer doesn't.
Q5How would you organize code: by layer or by feature?core▾

By feature (vertical slices: orders/, billing/, each with its own controller/service/repository) scales better because related code changes together. By layer is fine for small apps. Many teams combine: features at the top, layers inside.

Follow-ups interviewers ask
  • What's vertical slice architecture? Each request/feature is a self-contained slice from endpoint to data access.

🛠️ Practice exercises

Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.

✅ Check yourself

Answer the questions — feedback is instant.

1. Which dependency violates layering?
Lower layers must not know upper layers.
2. Strict layering means…
Relaxed layering allows skipping.
3. What problem does plain layering leave unsolved?
Clean architecture inverts that dependency.
4. Where do business rules belong?
Independent of delivery and storage.