Chapter 062-3 hours

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, Python, JavaScript, TypeScript and Java 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.

Clientbrowser / appServeryour backendHTTP request →← HTTP response

The two ends of every interaction. This manual zooms into the right-hand box.

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 requestEntry PointRoutingHandler / ControllerServiceRepository → DatabaseOS forwards :3000/users/:id → fn

The five internal stops. The response then climbs back up the same path.

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.

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.

Handlercontrollerdata in / outvalidatestatus codesServicebusiness logicprocessingorchestrateno HTTPRepositorydatabasebuild queryread / writeone job each

One responsibility per layer. The request flows left→right, the result returns right→left.

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. Spring goes further still and binds the body into a record before your method is even called. In Go and Python you typically deserialize explicitly inside the handler, into a struct, a dictionary, or a class. The step never disappears, it only moves: the further upstream it runs, the less of it you write yourself.

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

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
}

06The Service Layer

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

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")
}

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.

// 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
}

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

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

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.

entryMW 1routingMW 2handlerMW 3responseeach red box is an optional middleware sitting in a boundary

Middlewares occupy the boundaries between execution contexts.

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.

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 ===
    })
}

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.

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

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

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.

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 MWloggingperm checkhandlererror MWREQUEST 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.

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.

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

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

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.

Backend from First Principles / Chapter 06 / Layers. Code targets Go 1.22+, Python 3.11+, Node.js 20+, TypeScript 5+ and Java 17+ (Spring Boot).