Handlers,
Services & Repositories.
A first-principles walkthrough of how a single HTTP request travels inside your server — through routing, the three architectural layers, the middleware chain, and the request context that ties it all together. Complete implementations in Go and Python shown side by side.
01Client & Server
Everything begins with two participants talking over HTTP: a client (a browser, a mobile app, another service) and a server. The client sends a request; the server eventually sends back a response.
We have already studied the lifecycle external to the server — how the request is packaged, sent across the network, and how the response comes back. What we have not traced yet is what happens inside the server: the long sequence of events between the instant a request arrives and the instant a response leaves. That internal sequence is the subject of this manual.
02The Request Lifecycle Inside the Server
From the moment a request reaches the server until the moment a response is returned, a great deal happens. We call this the request lifecycle. To understand the architecture, it helps to trace the request from top to bottom — following it in the exact order it is processed: arrival, routing, the handler, the service, the repository, and finally the response on its way back out.
This top-to-bottom ordering is deliberate. Each topic builds on the last, mirroring the path the data physically takes through your code.
03Entry Point & Routing
When a request arrives, the operating system forwards the HTTP request to whichever port your server is listening on — :3000, :4000, or any port you configured. The point where your server first receives that request is its entry point. Your server is always listening on that port, and that listening is how it picks up incoming requests.
Routing
Immediately after the entry point comes routing. A server exposes many routes — /users, /users/123, dynamic routes like /users/:id, and so on. The routing algorithm inspects the incoming request's method and path and maps it to a particular handler: a predefined function responsible for that route.
A handler (also called a controller) is any function you have predefined to handle the requests for a given route. Routing's whole job is to decide which handler runs for which incoming request.
04Why Three Layers?
Once routing picks a handler, we encounter the three-part structure at the heart of a well-organized backend: handlers (controllers), services, and repositories. A natural question: why split things into three components instead of cramming all the logic into a single handler?
The honest answer: there is no hard requirement to separate them. You can do everything in one handler. The separation is a design pattern — a choice that buys you real benefits:
- Your codebase becomes scalable as it grows.
- It is more maintainable over time.
- It is easier to add features without breaking unrelated parts.
- It is easier to debug because each layer has one clear responsibility.
This is the principle of separation of concerns. The rest of Part II walks each layer in the order the request meets them.
05The Controller / Handler Layer
The handler is the entry point for a route once routing has matched it. Its overarching job is to control the flow of data — from the client into the server, and from the server back to the client. Crucially, in nearly every framework and language, your handler is given two things by the runtime itself.
Carries everything the client sent: method, path, headers, query parameters, body. You do not create it — Go, Express, Flask, etc. hand it to you on every request.
The handle through which you set status codes, modify response headers, and send the body back. Also provided automatically — you receive it, you don't construct it.
With those two objects in hand, the handler runs through a precise, ordered workflow.
- Data ExtractionPull the relevant data out of the request object. What you pull depends on the method: GET → query parameters; POST / PUT / PATCH → the request body; DELETE → usually nothing, sometimes a body.
- Binding (Deserialization)The body arrived as a JSON string because JSON is a serializable format that travels well across the front-end/back-end boundary. The handler deserializes it into the language's native type — a Go struct, a Python dict/class, a Rust struct. Frameworks call this binding. If it fails, halt immediately and return 400 Bad Request.
- ValidationConfirm the data matches the expected shape: mandatory fields present, types correct, no malicious payloads. Validate everything from an external client — path params, query params, and body — and be as specific as possible.
- TransformationOptionally reshape the validated data for the convenience of downstream layers — for example, injecting default values.
- DelegationPass the clean, validated, transformed data (plus context like the authenticated user ID) down to the Service layer.
- Sending the ResponseWhen the service returns, choose the correct status code and send the final response back to the client.
A note on Node.js vs Go & Python
In a Node.js/Express app the deserialization step often happens upstream in a middleware (the json() body parser), so JSON is already a JavaScript object by the time your handler runs. In Go and Python you typically deserialize explicitly inside the handler — into a struct, a dictionary, or a class.
Step 1–2 · Extract & bind the body; 400 on failure
// CreateBookRequest is our native format — the bind target. type CreateBookRequest struct { Title string `json:"title"` Author string `json:"author"` } func CreateBookHandler(w http.ResponseWriter, r *http.Request) { var req CreateBookRequest // Step 1 + 2: extract the body and deserialize (bind) into the struct. if err := json.NewDecoder(r.Body).Decode(&req); err != nil { // Deserialization failed -> the payload is malformed. http.Error(w, "invalid request body", http.StatusBadRequest) // 400 return // terminate the request here; do not proceed. } // ... validation, transformation, delegation follow ... }
from dataclasses import dataclass from flask import request, jsonify @dataclass class CreateBookRequest: title: str author: str def create_book_handler(): # Step 1 + 2: extract body and deserialize into a native dict/class. payload = request.get_json(silent=True) if payload is None: # Deserialization failed -> malformed payload. return jsonify({"error": "invalid request body"}), 400 req = CreateBookRequest(**payload) # ... validation, transformation, delegation follow ...
Step 3–4 · Validate, then transform (inject a default for an optional query param)
A good API design rule: make query parameters optional wherever possible. Consider GET /books?sort=name|date. If the client sends nothing, validation must still pass — so the transformation step injects a sensible default.
func ListBooksHandler(w http.ResponseWriter, r *http.Request) { sort := r.URL.Query().Get("sort") // "" if absent // VALIDATION: if present, it must be one of the allowed values. if sort != "" && sort != "name" && sort != "date" { http.Error(w, "sort must be 'name' or 'date'", http.StatusBadRequest) return } // TRANSFORMATION: query params are optional -> inject a default. if sort == "" { sort = "date" // downstream layers never see an empty value } books, err := bookService.ListBooks(r.Context(), sort) // delegate if err != nil { http.Error(w, "could not fetch books", http.StatusInternalServerError) // 500 return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(books) // 200 with the array of books }
def list_books_handler(): sort = request.args.get("sort") # None if absent # VALIDATION: if present, must be an allowed value. if sort is not None and sort not in ("name", "date"): return jsonify({"error": "sort must be 'name' or 'date'"}), 400 # TRANSFORMATION: optional param -> inject a default. if sort is None: sort = "date" try: books = book_service.list_books(sort) # delegate except Exception: return jsonify({"error": "could not fetch books"}), 500 return jsonify(books), 200 # array of books
06The Service Layer
The service layer is where the actual processing — the business logic — happens. The single most important rule governs it:
The service layer should know absolutely nothing about HTTP. No request/response objects, no status codes, no validation concerns. If you glance at a service method, you should not be able to tell it's used in an API at all. It is just a function: data in, processing, data out.
This isolation is what keeps the system decoupled. The service decides what to do; the handler decides how to report it over HTTP. That is where we draw the responsibility line.
Orchestration
A single service method can do a great deal. It can call one or several repository methods and merge their results, make external API calls, send emails, fire notifications — anything the business operation requires. This coordinating role is called orchestration: the service stitches together data from multiple sources and returns a single clean result to the handler. A service that only sends an email may never touch the repository at all.
// Notice: no http.Request, no ResponseWriter, no status codes. // You cannot tell from this signature that it serves an API. func (s *BookService) ListBooks(ctx context.Context, sort string) ([]Book, error) { // Orchestration: call the repository for the data it needs. books, err := s.repo.FindAllBooks(ctx, sort) if err != nil { return nil, err // bubble the error up; the handler decides the code } // Could also: enrich, merge other repo calls, send notifications... return books, nil } // A service that needs no database at all is perfectly valid: func (s *BookService) NotifyOwner(email string) error { return s.mailer.Send(email, "Your book was added") }
class BookService: def __init__(self, repo, mailer): self.repo = repo self.mailer = mailer # No request, no response, no status codes — just logic. def list_books(self, sort: str) -> list: # Orchestration: ask the repository for what it needs. books = self.repo.find_all_books(sort) # Could merge other repo calls, enrich, notify, etc. return books # A purely-logic service that never touches the DB: def notify_owner(self, email: str) -> None: self.mailer.send(email, "Your book was added")
07The Repository Layer
The repository (or database) layer has a single concern: talking to the database. It receives data from the service, constructs the database query — for inserting, filtering, or sorting — runs it, and returns the raw result back up to the service.
A repository method should do one thing and return one kind of data. Do not write a single method that, depending on an optional parameter, returns either one book or all books. Instead write two distinct methods: FindAllBooks() and FindBookByID(id).
// ONE method, ONE shape of result: all books, sorted. func (r *BookRepo) FindAllBooks(ctx context.Context, sort string) ([]Book, error) { // Build the query from the data the service handed down. query := fmt.Sprintf("SELECT id, title, author FROM books ORDER BY %s", sort) rows, err := r.db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var books []Book for rows.Next() { var b Book rows.Scan(&b.ID, &b.Title, &b.Author) books = append(books, b) } return books, nil } // A SEPARATE method for the single-book case. No optional toggles. func (r *BookRepo) FindBookByID(ctx context.Context, id int) (Book, error) { var b Book err := r.db.QueryRowContext(ctx, "SELECT id, title, author FROM books WHERE id = $1", id). Scan(&b.ID, &b.Title, &b.Author) return b, err }
class BookRepo: def __init__(self, db): self.db = db # ONE method, ONE result shape: all books, sorted. def find_all_books(self, sort: str) -> list: # Build the query from data passed down by the service. query = f"SELECT id, title, author FROM books ORDER BY {sort}" cur = self.db.execute(query) return [dict(row) for row in cur.fetchall()] # SEPARATE method for one book. No optional toggle parameter. def find_book_by_id(self, book_id: int) -> dict: cur = self.db.execute( "SELECT id, title, author FROM books WHERE id = ?", (book_id,)) return dict(cur.fetchone())
08The Full Lifecycle, End to End
Putting the layers together, here is the complete round trip for a single API call — say GET /books:
- BindingThe handler extracts data from the request object and deserializes it into the native format.
- Validation & TransformationThe handler validates the data and injects defaults / reshapes as needed.
- Service callThe handler delegates to the service, which performs the real processing — calling the repository for any database work.
- Result returnsThe repository returns rows to the service; the service returns a clean result to the handler.
- ResponseThe handler picks the status code and sends the response. 200 / 201 / 204 on success; 400 for client error; 500 for server error.
09What Middleware Is
Now replay the lifecycle, but watch the gaps. Between the entry point and routing, between routing and the handler, and between the handler and the response, there are boundaries — points where extra functions can run. The functions that run in those gaps are middlewares. The name is literal: they execute in the middle of the other execution contexts.
Middlewares are optional. There may be many, or none at all; a request can flow straight from routing to the handler. You add them only when a requirement calls for one.
A middleware is essentially a special kind of handler. Like a normal handler it receives the request object and the response object from the runtime — so it, too, can read values from the request, modify the response headers, and even send a response back to the client directly. But it receives a third thing as well, which Part 10 explains.
10The next() Function
The third thing a middleware receives is next, a function. Calling next() passes execution to the next context — whether that's the next middleware, the routing step, or the final handler. It's how the request crosses from one boundary to the next.
(1) Do its work, then call next() to continue the chain. (2) Not call next() and instead send a response immediately — short-circuiting the request before it ever reaches the handler. This early exit is exactly why a failed auth check or a tripped rate limit can stop a request cheaply.
Because a middleware has both the request and response objects, it can read and modify the request, and it can terminate the request right where it stands by returning a response. That is the full extent of a middleware's power.
// In Go, middleware wraps the "next" handler. Calling next.ServeHTTP // is the equivalent of next(): pass execution along the chain. func LoggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { log.Printf("%s %s", r.Method, r.URL.Path) // do work // EARLY EXIT example (short-circuit, never calls next): if r.Header.Get("X-Blocked") == "yes" { http.Error(w, "forbidden", http.StatusForbidden) return // request stops here } next.ServeHTTP(w, r) // === next(): continue the chain === }) }
# A WSGI/Flask-style middleware receives the request and a `next` # callable that invokes the rest of the chain. def logging_middleware(next): def wrapper(request): print(f"{request.method} {request.path}") # do work # EARLY EXIT example (short-circuit, never calls next): if request.headers.get("X-Blocked") == "yes": return Response("forbidden", status=403) # stops here return next(request) # === next(): continue the chain === return wrapper
Why middleware at all?
The same reason we use functions: to avoid repeating the same lines everywhere. A backend may serve thousands or millions of requests and expose hundreds of endpoints. Common operations — security, logging, authentication, parsing, compression — need to run for every request. Without middleware you would duplicate that code in every handler. Even extracting a helper function still forces you to call it in every handler. Middleware centralizes the common logic and applies it automatically across the chain.
11Why Order Matters
Each middleware uses next() to pass execution to the one after it, so the request flows through them sequentially, in the order you register them. That order is not cosmetic — it changes behavior.
The sharpest example is global error handling, which is typically placed last. Because the request flows in one direction, a middleware can only catch errors that originate upstream of it. If you placed the error handler in the middle, an error thrown later in the handler would flow past it and never be caught. Position it last and it can capture errors from anywhere earlier in the chain.
CORS first (reject foreign origins as early as possible) → logging → authentication → application-specific checks → handler → global error handling last. Spend real time deciding this order for your app.
12Common Middlewares
Each of the following qualifies as a middleware for the same two reasons: it must run for every request, and it needs to read the request and/or modify the response.
Browsers enforce a same-origin policy: a web app at example.com may only access resources from example.com unless the remote server sends the right headers. The CORS middleware reads the request's origin (provided automatically by the runtime); if it matches an allowed front-end origin, it adds the appropriate response headers so the browser won't block the response, then calls next(). If not, it omits the headers and the browser blocks it by default. Placed early.
Sets headers like Content-Security-Policy on every response, then forwards to the next middleware.
Extracts a token (JWT, session ID, etc.) from headers or payload and verifies it. On failure, it returns 401 Unauthorized immediately and terminates the request — no handler, no further middleware. On success, it extracts the user's details (user ID, role, permissions) and stores them in the request context before calling next().
Tracks how many requests an IP made within a window you define (e.g. 30 calls in 2 seconds). Over the threshold, it returns 429 Too Many Requests to protect server resources; under it, it calls next().
Records request details — path, method, query params, body — to the terminal or a log file for debugging, reporting, and auditing.
Catches any unstructured error from anywhere upstream, decides whether it is a client (4xx) or server (5xx) error, and returns a clean, structured message (often with a message and an error code the front end can map to a friendly message). Placed last.
Compresses large responses (e.g. gzip) so they travel efficiently; modern browsers transparently decompress them back into a usable format.
Serialization/deserialization — and even validation/transformation — can be delegated to a middleware (e.g. a body parser) so handlers don't repeat it. This is exactly why Node's json() body parser means JSON is already a JS object by the time a handler runs.
The CORS & Authentication middlewares in code
var allowedOrigin = "https://app.example.com" func CORSMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") // runtime gives us this if origin == allowedOrigin { w.Header().Set("Access-Control-Allow-Origin", origin) } next.ServeHTTP(w, r) // pass along; browser blocks if header absent }) } func AuthMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { token := r.Header.Get("Authorization") userID, role, err := verifyToken(token) if err != nil { http.Error(w, "unauthorized", http.StatusUnauthorized) // 401, stop return } // SUCCESS: stash identity in the request context, then continue. ctx := context.WithValue(r.Context(), "userID", userID) ctx = context.WithValue(ctx, "role", role) next.ServeHTTP(w, r.WithContext(ctx)) }) }
ALLOWED_ORIGIN = "https://app.example.com" def cors_middleware(next): def wrapper(request): resp = next(request) origin = request.headers.get("Origin") # runtime gives us this if origin == ALLOWED_ORIGIN: resp.headers["Access-Control-Allow-Origin"] = origin return resp return wrapper def auth_middleware(next): def wrapper(request): token = request.headers.get("Authorization") try: user_id, role = verify_token(token) except Exception: return Response("unauthorized", status=401) # stop # SUCCESS: stash identity in the request context, then continue. request.context["user_id"] = user_id request.context["role"] = role return next(request) return wrapper
13What Request Context Is
The auth middleware just did something subtle: it stored the user ID and role somewhere so that a later handler could read them. That "somewhere" is the request context.
A piece of storage or state — usually a simple key–value store — that is scoped to a single HTTP request. Every request gets its own context; it does not leak into other requests. Every framework in every language (Node, Go, Python) ships some implementation of this concept; the mechanics differ, the idea is the same.
Why does it exist? Because a request passes through many isolated function boundaries — CORS, logging, routing, auth, permission checks, the handler, the error handler. The context gives all of them a shared place to read and write state without tightly coupling them — without one middleware having to explicitly pass values into the next by hand.
14Use Case · Passing Authentication Data
The canonical use. The auth middleware verifies credentials, extracts the user_id and role, and writes them into the context. Far downstream, a POST /books handler needs to stamp the new book with its owner. Instead of trusting a user_id sent in the client's JSON payload, it reads the ID from the context.
If you took the user_id from the client payload, a malicious client could send someone else's ID and act as them. Reading identity from the context — populated only by your verified auth middleware — closes that hole. The same context-stored role drives permission checks (is this user an admin? does it have write access?).
func CreateBookHandler(w http.ResponseWriter, r *http.Request) { var req CreateBookRequest json.NewDecoder(r.Body).Decode(&req) // Read the trusted user ID FROM THE CONTEXT, not from req. // The auth middleware put it there after verifying the token. userID := r.Context().Value("userID").(int) role := r.Context().Value("role").(string) if role != "admin" && role != "user" { http.Error(w, "forbidden", http.StatusForbidden) return } // Persist with the SERVER-VERIFIED owner id — never the client's. book, _ := bookService.Create(r.Context(), req, userID) w.WriteHeader(http.StatusCreated) // 201 json.NewEncoder(w).Encode(book) }
def create_book_handler(request): req = CreateBookRequest(**request.get_json()) # Read the trusted user id FROM THE CONTEXT, not from the body. # The auth middleware put it there after verifying the token. user_id = request.context["user_id"] role = request.context["role"] if role not in ("admin", "user"): return Response("forbidden", status=403) # Persist with the SERVER-VERIFIED owner id — never the client's. book = book_service.create(req, user_id) return jsonify(book), 201
15Use Cases · Tracing & Cancellation
Request tracing
An early middleware can generate a unique ID (a UUID) and save it in the context. For the entire lifecycle of that request, every log line can include this ID, and any outbound calls to other microservices can forward it in a header like X-Request-ID. When you later audit your logs to debug a problem, that single ID lets you trace one request across every service it touched — where it started and everywhere it went.
func RequestIDMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { id := uuid.NewString() // one unique id for this request ctx := context.WithValue(r.Context(), "requestID", id) w.Header().Set("X-Request-ID", id) // echo it back / forward it log.Printf("[%s] %s %s", id, r.Method, r.URL.Path) next.ServeHTTP(w, r.WithContext(ctx)) }) }
import uuid def request_id_middleware(next): def wrapper(request): rid = str(uuid.uuid4()) # one unique id for this request request.context["request_id"] = rid print(f"[{rid}] {request.method} {request.path}") resp = next(request) resp.headers["X-Request-ID"] = rid # echo it back / forward it return resp return wrapper
Cancellation, abort signals & deadlines
The request context is also the standard place to carry cancellation signals and deadlines. If a client disconnects or a deadline passes, those signals propagate to downstream external calls, so a service never hangs perpetually waiting on work that no longer matters. In Go this is the built-in context.Context with WithTimeout; in Python it surfaces as timeouts / cancellation tokens passed along the call chain.
A request enters at the entry point, flows through an ordered chain of middlewares and routing, lands in a handler that validates and transforms the data, delegates to a service that orchestrates the business logic, which calls repositories for database work — all while a per-request context carries identity, a trace ID, and deadlines alongside it. The result climbs back up, and the handler chooses a status code and sends the response. That is the full request lifecycle.