Handlers, Controllers & Services
Layered architecture. Why your service layer should never know about HTTP.
Why one big function stops working
Every backend starts the same way. One route, one function, everything in it:
app.post('/orders', async (req, res) => {
if (!req.body.items || req.body.items.length === 0)
return res.status(400).json({ error: 'No items' });
const user = verifyToken(req.headers.authorization);
if (!user) return res.status(401).json({ error: 'Unauthorized' });
let total = 0;
for (const item of req.body.items) {
const product = await db.query('SELECT * FROM products WHERE id = $1', [item.id]);
if (product.stock < item.qty)
return res.status(409).json({ error: 'Out of stock' });
total += product.price * item.qty;
}
const order = await db.query('INSERT INTO orders ...', [user.id, total]);
await sendEmail(user.email, 'Order confirmed');
res.status(201).json(order);
});
For one endpoint, this is fine. The problem is what happens next. The "place an order" logic — stock checks, total calculation — is now welded to HTTP. When you need to place an order from a different entry point — an admin tool, a scheduled job, a CLI, a gRPC endpoint — you cannot reuse any of it without dragging req and res along. You copy-paste, and now the rule lives in two places.
Testing is the other tell. To test the pricing rule you have to construct a fake req, a fake res, mock the database, and make a pretend HTTP call — all to check one multiplication. The business logic is unreachable except through HTTP.
The layered architecture exists to break that weld.
The three layers
Almost every backend, in every language, converges on the same three layers. The names vary; the responsibilities do not.
HTTP request
│
▼
┌──────────────────────────────────────┐
│ HANDLER / CONTROLLER │ ← speaks HTTP
│ parse request · call service · │
│ shape response · choose status code │
└──────────────────────────────────────┘
│ plain data in, plain data out
▼
┌──────────────────────────────────────┐
│ SERVICE │ ← business logic
│ rules, calculations, orchestration │
│ knows nothing about HTTP │
└──────────────────────────────────────┘
│ domain calls
▼
┌──────────────────────────────────────┐
│ REPOSITORY │ ← data access
│ queries, persistence │
│ knows nothing about business rules │
└──────────────────────────────────────┘
│
▼
database
The rule that makes it work: each layer talks only to the layer directly below it, and knows nothing about the layer above. The service does not know it was called by HTTP. The repository does not know what business rule triggered the query. Information flows down as plain data and back up as plain data.
The next three sections take each layer in turn.
The handler — translating HTTP
The handler (Express and Go call it a handler; Spring and Rails call it a controller — same thing) is the layer that speaks HTTP. That is its entire job, and the job has exactly four parts:
- Extract what it needs from the request — path params, query string, body, headers.
- Call the service with plain values. No
reqobject crosses this line. - Translate the service's result or error into an HTTP status code.
- Serialize the response.
async function createOrderHandler(req, res, next) {
try {
// 1. extract — pull plain values out of the HTTP request
const userId = req.user.id;
const items = req.body.items;
// 2. call — hand the service plain data, no req/res
const order = await orderService.placeOrder(userId, items);
// 3 + 4. translate to HTTP and serialize
res.status(201).json(order);
} catch (err) {
next(err); // let error middleware map the error to a status
}
}
What is not here is the point. No pricing maths. No stock check. No SQL. The handler is thin — a translator between the HTTP world and the plain-data world. If you can describe a handler's body as "unwrap the request, call one service method, wrap the response," it is the right size.
A handler may be HTTP-specific in another way too: a GraphQL resolver or a gRPC handler is a different handler over the same service. That is the payoff — swap the protocol layer, keep the logic.
The service — where the business lives
The service layer holds the part of the system that would still be true if HTTP had never been invented. "An order's total is the sum of price times quantity." "You cannot order more than the available stock." Those are facts about the business, not about the web.
class OrderService {
async placeOrder(userId, items) {
if (!items || items.length === 0)
throw new ValidationError('Order must contain at least one item');
let total = 0;
for (const item of items) {
const product = await this.productRepo.findById(item.id);
if (!product) throw new NotFoundError(`Product ${item.id} not found`);
if (product.stock < item.qty)
throw new ConflictError(`${product.name} is out of stock`);
total += product.price * item.qty;
}
const order = await this.orderRepo.create({ userId, items, total });
await this.emailService.sendOrderConfirmation(userId, order);
return order;
}
}
Three things to notice. It takes plain arguments — userId, items — not a req. It could be called from a handler, a job, a test, or a CLI without changing a line. It throws domain errors — ValidationError, NotFoundError, ConflictError — not HTTP status codes. The service does not know 409 exists; it knows "conflict." The handler's error middleware maps the domain error to the status. It orchestrates — it is the layer allowed to call several repositories and other services to carry out one operation.
This is also the layer that is genuinely pleasant to test. placeOrder with a fake productRepo is a plain function call: pass items, assert on the total or the thrown error. No HTTP machinery.
The repository — isolating data access
The repository is the only layer that knows how data is stored. It turns domain language — "find the user by id," "save this order" — into queries, and turns query results back into plain objects.
class OrderRepository {
async create(orderData) {
const row = await db.query(
`INSERT INTO orders (user_id, total, status)
VALUES ($1, $2, 'pending') RETURNING *`,
[orderData.userId, orderData.total]
);
return this.toOrder(row); // DB row → plain domain object
}
async findById(id) {
const row = await db.query('SELECT * FROM orders WHERE id = $1', [id]);
return row ? this.toOrder(row) : null;
}
}
The service calls orderRepo.create(...) and orderRepo.findById(...). It never sees SQL. That boundary buys two things. Swappability — move from raw SQL to an ORM, or Postgres to DynamoDB, and only the repository changes; the service is untouched because its calls did not change. Testability — a fake repository implementing the same create / findById methods lets you test the service with zero database.
Whether you write a formal Repository class or just keep all data access in a db/ module is a judgement call — small apps do fine with the lighter version. The non-negotiable part is the boundary: SQL and storage details stay on one side of it, business logic on the other. The moment a SELECT appears inside a service method, the boundary has leaked.
Common mistakes
The fat controller. The single most common failure: business logic creeping back into the handler. A stock check here, a total calculation there. Each addition seems harmless; the result is the welded mess from section one, slowly reassembled. The test: a handler should not contain a calculation, a business if, or a loop over domain rules. If it does, that code wants to be in the service.
The anaemic service that is just a passthrough. The opposite failure: a service whose every method is one line — return this.repo.findById(id). If a service does nothing but forward calls, it is ceremony, not architecture. That is a real signal — see the next section.
SQL in the service. A query string inside a service method means the repository boundary leaked. The service now knows your schema and your database dialect. Move the query down.
HTTP in the service. A service that returns res.status(404) or reads req.headers has the boundary leaking the other way. It can no longer be called from a job or a test. Services throw domain errors and take plain arguments — no exceptions.
Layer-skipping. A handler reaching straight into the repository "because it is just a quick read" erodes the structure. Today it is a read; tomorrow someone adds a rule and there is no service to hold it. Keep the call path handler → service → repository even when the service method is currently thin.
Circular calls between services. OrderService calls UserService calls OrderService. This usually means a responsibility is in the wrong place. Extract the shared logic into a third service both can depend on, so the dependency graph stays a tree.
When the layers are overkill
The honest counterweight: layered architecture has a cost, and it is not always worth paying.
The cost is indirection. A trivial "fetch a row and return it as JSON" endpoint, split across three layers, becomes a handler that calls a service method that calls a repository method — three files, three function definitions, to express one SELECT. For a genuine CRUD endpoint with no rules, the service is a passthrough that adds a hop and earns nothing.
So calibrate, do not cargo-cult:
- A small app, a prototype, an internal tool with a handful of endpoints and no real domain logic — a handler that talks to the database directly is fine. Two layers, or even one. Do not build a cathedral for a shed.
- CRUD-heavy endpoints with no business rules can legitimately skip the service layer and let the handler use the repository directly. Add the service the day an actual rule appears — not before, on speculation.
- The layers earn their keep when there is real business logic: rules, multi-step operations, calculations, several data sources orchestrated into one result, more than one entry point into the same logic. That is when "where does this rule live" has a clear answer and the structure stops you from copy-pasting it.
The principle underneath all of it is separation of concerns — HTTP translation, business rules, and data access are different jobs and mixing them hurts. The three-layer structure is the most common way to honour that principle, not a law. Apply it in proportion to the domain complexity you actually have. A backend with genuine rules will be glad of the layers; a five-endpoint CRUD service will just be slower to read. Next module: databases — what the repository layer is actually talking to.
The Backend from First Principles series is based on what I learnt from Sriniously's YouTube playlist — a thoughtful, framework-agnostic walk through backend engineering. If this material helped you, please go check the original out: youtube.com/@Sriniously. The notes here are my own restatement for revisiting later.