A Detailed Backend Reference

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.

Separation of Concerns Request Lifecycle Middleware Chain Go + Python · full code 15 sections
Part I · The Request's Journey01 / 15

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.

Client browser · app Server your backend HTTP request → ← HTTP response
The two ends of every interaction. This manual zooms into the right-hand box.
Part I · The Request's Journey02 / 15

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.

The journey of one request Entry Point Routing Handler / Controller Service Repository → Database OS forwards :3000 /users/:id → fn
The five internal stops. The response then climbs back up the same path.
Part I · The Request's Journey03 / 15

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.

Definition · Handler

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.

Part I · The Request's Journey04 / 15

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.

Handler controller data in / out validate status codes Service business logic processing orchestrate no HTTP Repository database build query read / write one job each
One responsibility per layer. The request flows left→right, the result returns right→left.
Part II · The Three Layers05 / 15

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.

Provided by the runtimeThe request object

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.

Provided by the runtimeThe response object

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.

  1. Data Extraction
    Pull 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.
  2. 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.
  3. Validation
    Confirm 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.
  4. Transformation
    Optionally reshape the validated data for the convenience of downstream layers — for example, injecting default values.
  5. Delegation
    Pass the clean, validated, transformed data (plus context like the authenticated user ID) down to the Service layer.
  6. Sending the Response
    When 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

create_book · handler
// 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 ...
}

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.

list_books · validate + transform
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
}
Part II · The Three Layers06 / 15

06The Service Layer

The service layer is where the actual processing — the business logic — happens. The single most important rule governs it:

The Golden Rule of Services

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.

book_service · pure business logic
// 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")
}
Part II · The Three Layers07 / 15

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.

Single Responsibility

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).

book_repository · one job per method
// 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
}
Part II · The Three Layers08 / 15

08The Full Lifecycle, End to End

Putting the layers together, here is the complete round trip for a single API call — say GET /books:

  1. Binding
    The handler extracts data from the request object and deserializes it into the native format.
  2. Validation & Transformation
    The handler validates the data and injects defaults / reshapes as needed.
  3. Service call
    The handler delegates to the service, which performs the real processing — calling the repository for any database work.
  4. Result returns
    The repository returns rows to the service; the service returns a clean result to the handler.
  5. Response
    The handler picks the status code and sends the response. 200 / 201 / 204 on success; 400 for client error; 500 for server error.
200 OK 201 Created 204 No Content 400 Bad Request 500 Internal Server Error
Client Handlervalidate Servicelogic Repositoryquery DB request flows → down the layers ← result returns back up → response
Handlers move data formats in and out · the service does the processing · the repository does the database work.
Part III · Middleware09 / 15

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.

entry MW 1 routing MW 2 handler MW 3 response each red box is an optional middleware sitting in a boundary
Middlewares occupy the boundaries between execution contexts.
Part III · Middleware10 / 15

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.

Two ways a middleware ends

(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.

middleware shape · req, res, next
// 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 ===
    })
}

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.

Part III · Middleware11 / 15

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.

A sensible default ordering

CORS first (reject foreign origins as early as possible) → loggingauthentication → application-specific checks → handlerglobal error handling last. Spend real time deciding this order for your app.

Part III · Middleware12 / 15

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.

SecurityCORS

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.

SecuritySecurity headers

Sets headers like Content-Security-Policy on every response, then forwards to the next middleware.

SecurityAuthentication

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().

SecurityRate limiting

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().

ObservabilityLogging & monitoring

Records request details — path, method, query params, body — to the terminal or a log file for debugging, reporting, and auditing.

ReliabilityGlobal error handling

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.

PerformanceCompression

Compresses large responses (e.g. gzip) so they travel efficiently; modern browsers transparently decompress them back into a usable format.

ConvenienceData parsing

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

cors + auth middleware
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))
    })
}
Part IV · Request Context13 / 15

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.

Definition · 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.

auth MW logging perm check handler error MW REQUEST CONTEXT · shared key–value state, scoped to this one request { user_id, role, request_id, deadline }
Every boundary can read and write the same per-request store — no manual hand-off needed.
Part IV · Request Context14 / 15

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.

Security — why this matters

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?).

handler reads identity from context
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)
}
Part IV · Request Context15 / 15

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.

request-id middleware
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))
    })
}

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.

The whole picture

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.