NodeJSLayered Architecture

Layered Architecture

Layered architecture is the most common way to structure a backend application: code is organized into horizontal layers — typically presentation (routes/controllers), business logic (services), and data access (repositories/DAL) — where each layer has a single responsibility and communicates only with the layer directly below it. The layering enforces separation of concerns: business logic doesn't know about HTTP, data access doesn't know about business rules, and the presentation layer doesn't touch the database. The result is a codebase where each piece is independently changeable and testable.

The three layers

Text
HTTP Request
     │
     ▼
┌─────────────────────────────────────────┐
│  PRESENTATION LAYER (routes/controllers) │  ← Knows: HTTP, req/res, validation
│  Parse request, call service, send res   │     Doesn't know: business rules, SQL
└────────────────────┬────────────────────┘
                     │ calls
                     ▼
┌─────────────────────────────────────────┐
│  SERVICE LAYER (business logic)          │  ← Knows: domain rules, orchestration
│  Enforce rules, orchestrate operations   │     Doesn't know: HTTP, SQL
└────────────────────┬────────────────────┘
                     │ calls
                     ▼
┌─────────────────────────────────────────┐
│  REPOSITORY / DATA ACCESS LAYER          │  ← Knows: SQL, ORM, cache keys
│  All reads and writes to the database    │     Doesn't know: business rules, HTTP
└─────────────────────────────────────────┘
     │
     ▼
  Database / Cache / External APIs
Each layer talks only to the one below — the boundary prevents business logic from bleeding into HTTP handlers or SQL
The rule is **one-direction dependency**: the presentation layer calls the service layer, which calls the repository layer — never the other way around, and layers don't skip over each other (controllers don't call repositories directly). Each layer knows about its own domain (HTTP, business rules, SQL) and is **deliberately ignorant** of the others. A service doesn't import `express`; it neither receives a `Request` object nor returns a `Response`. A repository doesn't contain an `if` that encodes a business rule. When a layer stays ignorant of its neighbors' concerns, you can swap the database without touching services, change the HTTP framework without touching business logic, and test each layer in isolation — replacing the layer below with a stub or in-memory fake.
Concrete example — a users feature

TS
// ── Presentation layer: knows about HTTP, delegates to service ──────────────
// users.controller.ts
export async function createUser(req: Request, res: Response) {
  const dto = CreateUserSchema.parse(req.body)   // validate — service gets clean data
  const user = await usersService.create(dto)
  res.status(201).json(user)
}

// ── Service layer: knows about business rules, delegates persistence ──────────
// users.service.ts
export async function create(dto: CreateUserDto) {
  const existing = await usersRepo.findByEmail(dto.email)
  if (existing) throw new ConflictError('Email already in use')
  const hashed = await bcrypt.hash(dto.password, 12)
  return usersRepo.insert({ ...dto, password: hashed })
}

// ── Repository layer: knows about the database, nothing else ─────────────────
// users.repository.ts
export async function findByEmail(email: string) {
  return db.query('SELECT * FROM users WHERE email = $1', [email]).then(r => r.rows[0])
}
export async function insert(data: NewUser) {
  return db.query('INSERT INTO users ...').then(r => r.rows[0])
}
The controller validates and delegates; the service enforces rules; the repository runs queries — each layer does only its job
Trace a `POST /users` request through the layers. The **controller** parses and validates the HTTP body (using a schema), calls the service with a clean data-transfer object, and formats the HTTP response — it has zero business logic. The **service** enforces business rules: check for a duplicate email, hash the password — it has no SQL and no `req`/`res`. The **repository** runs the actual database query — it has no business rules, just parameterized queries. Each change is isolated: need to change the password hashing algorithm? Edit the service only. Switch from raw SQL to an ORM? Edit only the repository. Add a REST endpoint and a GraphQL resolver for the same operation? Both call the same service. This is the compounding value of the layering discipline.
What belongs in each layer

Layer

Belongs here

Never here

Presentation

Route definition, request parsing, schema validation, auth middleware, req/res handling

Business rules, direct DB calls, domain logic

Service

Business rules, domain logic, orchestration, authorization checks, events

HTTP concepts (req/res), SQL/ORM, third-party SDK specifics

Repository

SQL queries, ORM calls, cache reads/writes, data mapping

Business logic, if statements encoding rules, HTTP

The most common violation: putting business logic in controllers, or SQL directly in route handlers — catch it in code review
The most pervasive violation of layered architecture isn't a deliberate choice — it's gradual drift. A controller that starts thin grows a business-logic `if`; a route handler gets a quick `db.query()` added "just this once." Over time the layers collapse, and you end up with 300-line controllers containing SQL, business rules, and HTTP logic all entangled — impossible to test without a running database and server, and a nightmare to change without side effects. The fix is a firm **code-review rule**: `req`, `res`, and validation live in controllers; domain rules live in services; SQL and ORM calls live in repositories. No exceptions, or the architecture degrades. If business logic is leaking into a controller, extract it to a service method, even for a single line.
Next
A specific pattern that names the three layers: [The MVC Pattern](/nodejs/mvc-pattern).