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
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 APIsConcrete example — a users feature
// ── 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])
}What belongs in each layer
Layer | Belongs here | Never here |
|---|---|---|
Presentation | Route definition, request parsing, schema validation, auth middleware, | Business rules, direct DB calls, domain logic |
Service | Business rules, domain logic, orchestration, authorization checks, events | HTTP concepts ( |
Repository | SQL queries, ORM calls, cache reads/writes, data mapping | Business logic, |