How to structure a backend
1 September 2026 · 5 min read · #backend #architecture #http
written from this video
A request arrives on port 3000 and eventually a JSON body goes back out. Everything in between is architecture, and almost all of the arguments about backend structure are arguments about one thing: where does this particular line of code belong?
Handlers, services, repositories, middleware, and request context are five answers. None of them are requirements — you can write a working backend where every route is one 300-line function. They're conventions that exist because that 300-line function stops being debuggable around month four.
The three layers, and the line between them
Routing maps a request to a handler. From there:
The handler owns data formats. It gets the request and response objects from your runtime — (req, res) in Express, (w *http.ResponseWriter, r *http.Request) in Go — and its job is translation. Pull values off the request. Deserialize the JSON body into a native type: a struct in Go, a class or dict in Python. Most frameworks call this binding. If binding fails, the handler returns 400 and the request stops there.
Then validate. Every path parameter, every query param, every body field, as specifically as you can express it. Then transform — mostly setting defaults so downstream code doesn't have to branch on absence:
GET /books -> sort = "date" (default applied here)
GET /books?sort=name -> sort = "name"
That's a small thing that pays for itself. Make all query params optional as an API design rule, then normalise them in the handler, and your service layer never contains if sort == nil.
The service owns the actual work. This is the layer people get wrong. The test is simple: read a service method and you should not be able to tell it's used by an API. No status codes, no headers, no request object. It takes data, does something, returns data. Sending an email, charging a card, merging results from three different queries — that's a service. It's also the orchestration layer: one service method can call four repository methods and stitch the results together.
The repository owns queries. It takes data, builds a query, returns what the database gave back. One method, one shape of result. If you have getBooks(id?) that returns a list when id is nil and a single book when it isn't, you have two functions wearing a trench coat. Split them.
Then control flows back up. The handler picks the status code — 200, 201, 204 on success, 400 for a client's mistake, 500 for yours — and serializes the response. The handler controls the flow; it does not do the work.
The line is HTTP. Anything that knows what a status code is stays in the handler. Anything that doesn't, goes below.
Middleware is a function that runs at a boundary
Every one of those transitions — entry to routing, routing to handler, handler to response — is a boundary, and you can insert a function at any of them. That's all middleware is.
Middleware gets the same request and response objects a handler gets, plus a third thing: next. Calling next() hands execution to whatever comes after. Not calling it ends the request right there, which is the whole point — a middleware holding a response object can answer the client before the handler ever runs.
The reason to use one instead of a helper function called at the top of every handler is that calling the helper in 300 handlers is still 300 places to forget it. Common examples:
- CORS reads
Origin, and if it matches an allowed origin, sets theAccess-Control-Allow-*headers. Runs first, so an unwelcome origin gets rejected before you spend anything on it. - Security headers — CSP and friends, set on every response.
- Auth pulls the token, verifies it, returns 401 and stops on failure. On success it attaches the user's identity to the request and calls
next(). - Rate limiting counts requests per IP over a window and returns 429 past the threshold.
- Logging records method, path, params, timing.
- Compression gzips large response bodies on the way out.
- Global error handling catches whatever blew up anywhere in the chain and turns it into a structured response.
Order is not cosmetic. Execution runs one direction, so error handling goes last: put it in the middle and an exception thrown in your handler flows past a middleware that already ran. CORS goes first for the opposite reason — you want the cheap rejection to happen before the expensive work.
Request context: the state that dies with the request
The auth middleware verified a token and now knows the user's ID and role. The handler, four boundaries later, needs both. You could thread them through every function signature between here and there, which couples every layer to the shape of your auth. Instead you put them somewhere scoped to this one request.
That's request context: a key-value store attached to a single in-flight request, readable and writable by every middleware and handler that touches it, gone when the response is sent. Go has context.Context, Node has AsyncLocalStorage and req.locals, Python has contextvars. Different implementations, same idea.
The auth case matters for a security reason, not just a plumbing one. When your POST /books handler needs a user_id for the row it's inserting, take it from context — not from the request body. A client that supplies its own user_id is a client that can write rows as somebody else.
Two more things that belong in context:
A request ID. Generate a UUID in an early middleware, log it everywhere, forward it as X-Request-ID on outbound calls. When something breaks across four services, that ID is the difference between reading logs and grepping them.
Cancellation. If the client disconnects, the context is cancelled, and a downstream call that respects it stops instead of holding a connection open for a response nobody will read. This is Go's context.Context doing its actual job — deadlines and abort signals, not just value storage.
Context is the answer to "how does information cross a boundary without the layers knowing about each other." Handlers, services, and repositories are the answer to "which side of the boundary does this belong on." Middleware is the answer to "what runs on every request." That's the whole shape of a request.