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.
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 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.
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.
-
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.
-
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.
-
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.
-
Transformation
Optionally reshape the validated data for the convenience of downstream layers, for example, injecting default values.
-
Delegation
Pass the clean, validated, transformed data (plus context like the authenticated user ID) down to the Service layer.
-
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 ...
}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 ...// Express's json() body parser has already deserialized the body upstream,
// so req.body is a JavaScript object by the time the handler runs. That
// makes step 1 free and turns step 2 into "check the shape you expected".
app.post('/books', (req, res) => {
// Step 1 + 2: extract the body and bind the fields we accept.
const { title, author } = req.body ?? {}
// express.json() leaves req.body empty when the payload is malformed.
if (typeof title !== 'string' || typeof author !== 'string') {
// Binding failed -> the payload is malformed.
return res.status(400).json({ error: 'invalid request body' }) // 400
}
const createBookRequest = { title, author }
// ... validation, transformation, delegation follow ...
})import type { Request, Response } from 'express'
// CreateBookRequest is our native format, the bind target.
interface CreateBookRequest {
title: string
author: string
}
// A parsed body is `unknown` until something proves its shape. A type
// guard is that proof, and it is what lets the rest of the handler treat
// `req.body` as a CreateBookRequest without a cast.
function isCreateBookRequest(value: unknown): value is CreateBookRequest {
const body = value as Partial<CreateBookRequest> | null
return typeof body?.title === 'string' && typeof body?.author === 'string'
}
export function createBookHandler(req: Request, res: Response): void {
// Step 1 + 2: extract the body and deserialize (bind) into the type.
if (!isCreateBookRequest(req.body)) {
// Deserialization failed -> the payload is malformed.
res.status(400).json({ error: 'invalid request body' }) // 400
return // terminate the request here; do not proceed.
}
const body: CreateBookRequest = req.body
// ... validation, transformation, delegation follow ...
}// CreateBookRequest is our native format, the bind target.
public record CreateBookRequest(String title, String author) {}
@RestController
class BookController {
// Step 1 + 2 happen BEFORE this method runs: @RequestBody tells Spring
// to read the body and let Jackson deserialize it into the record.
@PostMapping("/books")
ResponseEntity<?> createBook(@RequestBody CreateBookRequest req) {
// ... validation, transformation, delegation follow ...
return ResponseEntity.status(HttpStatus.CREATED).build(); // 201
}
// A body Jackson cannot read never reaches the handler, it arrives
// here instead. Same outcome as the explicit `if` above: 400.
@ExceptionHandler(HttpMessageNotReadableException.class)
ResponseEntity<?> onUnreadableBody() {
return ResponseEntity.badRequest()
.body(Map.of("error", "invalid request body")); // 400
}
}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 booksapp.get('/books', async (req, res) => {
const sort = req.query.sort // undefined if absent
// VALIDATION: if present, it must be one of the allowed values.
if (sort !== undefined && sort !== 'name' && sort !== 'date') {
return res.status(400).json({ error: "sort must be 'name' or 'date'" })
}
// TRANSFORMATION: query params are optional -> inject a default.
const sortBy = sort ?? 'date' // downstream layers never see undefined
try {
const books = await bookService.listBooks(sortBy) // delegate
res.status(200).json(books) // 200 with the array of books
} catch {
res.status(500).json({ error: 'could not fetch books' }) // 500
}
})import type { Request, Response } from 'express'
const SORTS = ['name', 'date'] as const
type Sort = (typeof SORTS)[number]
export async function listBooksHandler(req: Request, res: Response): Promise<void> {
const sort = req.query.sort // string | string[] | undefined | ...
// VALIDATION: if present, it must be one of the allowed values.
if (sort !== undefined && !SORTS.includes(sort as Sort)) {
res.status(400).json({ error: "sort must be 'name' or 'date'" })
return
}
// TRANSFORMATION: optional param -> inject a default. Note what the
// narrowed type buys you: the service takes a `Sort`, so a typo in a
// future edit stops compiling instead of reaching the database.
const sortBy: Sort = (sort as Sort | undefined) ?? 'date'
try {
const books = await bookService.listBooks(sortBy) // delegate
res.status(200).json(books) // 200 with the array of books
} catch {
res.status(500).json({ error: 'could not fetch books' }) // 500
}
}@GetMapping("/books")
ResponseEntity<?> listBooks(@RequestParam(required = false) String sort) {
// VALIDATION: if present, it must be one of the allowed values.
if (sort != null && !sort.equals("name") && !sort.equals("date")) {
return ResponseEntity.badRequest()
.body(Map.of("error", "sort must be 'name' or 'date'")); // 400
}
// TRANSFORMATION: query params are optional -> inject a default.
// (@RequestParam(defaultValue = "date") would do exactly this for you;
// it is spelled out here so the step stays visible.)
String sortBy = (sort == null) ? "date" : sort;
try {
List<Book> books = bookService.listBooks(sortBy); // delegate
return ResponseEntity.ok(books); // 200 with the array of books
} catch (DataAccessException e) {
return ResponseEntity.internalServerError()
.body(Map.of("error", "could not fetch books")); // 500
}
}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")
}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")// Notice: no req, no res, no status codes.
// You cannot tell from these methods that they serve an API.
class BookService {
constructor(repo, mailer) {
this.repo = repo
this.mailer = mailer
}
async listBooks(sort) {
// Orchestration: call the repository for the data it needs.
const books = await this.repo.findAllBooks(sort)
// Could also: enrich, merge other repo calls, send notifications...
return books
}
// A service that needs no database at all is perfectly valid:
async notifyOwner(email) {
await this.mailer.send(email, 'Your book was added')
}
}// The types tell the same story the comments do: every name in this
// signature is a domain word (Book, Sort, Mailer). Not one of them is
// an HTTP word, which is exactly the isolation we are after.
export class BookService {
constructor(
private readonly repo: BookRepo,
private readonly mailer: Mailer,
) {}
async listBooks(sort: Sort): Promise<Book[]> {
// Orchestration: ask the repository for what it needs.
const books = await this.repo.findAllBooks(sort)
// Could merge other repo calls, enrich, notify, etc.
return books
}
// A purely-logic service that never touches the DB:
async notifyOwner(email: string): Promise<void> {
await this.mailer.send(email, 'Your book was added')
}
}// No HttpServletRequest, no ResponseEntity, no status codes. Nothing here
// says "web", this class would work unchanged behind a CLI or a queue.
@Service
public class BookService {
private final BookRepo repo;
private final Mailer mailer;
BookService(BookRepo repo, Mailer mailer) {
this.repo = repo;
this.mailer = mailer;
}
public List<Book> listBooks(String sort) {
// Orchestration: call the repository for the data it needs.
List<Book> books = repo.findAllBooks(sort);
// Could also: enrich, merge other repo calls, send notifications...
return books;
}
// A service that needs no database at all is perfectly valid:
public void notifyOwner(String email) {
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
}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())class BookRepo {
constructor(db) {
this.db = db // a node-postgres Pool
}
// ONE method, ONE shape of result: all books, sorted.
async findAllBooks(sort) {
// A column name can never be a bound parameter, so it goes through a
// fixed lookup rather than into the string. Values always use $1, $2.
const column = { name: 'title', date: 'created_at' }[sort] ?? 'created_at'
const { rows } = await this.db.query(
`SELECT id, title, author FROM books ORDER BY ${column}`)
return rows
}
// SEPARATE method for one book. No optional toggle parameter.
async findBookById(id) {
const { rows } = await this.db.query(
'SELECT id, title, author FROM books WHERE id = $1', [id])
return rows[0] ?? null
}
}import type { Pool } from 'pg'
const SORT_COLUMNS: Record<Sort, string> = { name: 'title', date: 'created_at' }
export class BookRepo {
constructor(private readonly db: Pool) {}
// ONE method, ONE shape of result: all books, sorted.
async findAllBooks(sort: Sort): Promise<Book[]> {
// Because `sort` is a `Sort` and not a `string`, the lookup is total:
// there is no value it could hold that produces a column we did not
// write ourselves. The type is the injection defence.
const { rows } = await this.db.query<Book>(
`SELECT id, title, author FROM books ORDER BY ${SORT_COLUMNS[sort]}`)
return rows
}
// SEPARATE method for one book. No optional toggle parameter.
// The return type says "may be missing" out loud, so the service has
// to decide what that means instead of tripping over undefined.
async findBookById(id: number): Promise<Book | null> {
const { rows } = await this.db.query<Book>(
'SELECT id, title, author FROM books WHERE id = $1', [id])
return rows[0] ?? null
}
}@Repository
public class BookRepo {
// A column name can never be a bound parameter, so it goes through a
// fixed map rather than into the string. Values always use ?.
private static final Map<String, String> SORT_COLUMNS =
Map.of("name", "title", "date", "created_at");
private final JdbcClient db;
BookRepo(JdbcClient db) {
this.db = db;
}
// ONE method, ONE shape of result: all books, sorted.
public List<Book> findAllBooks(String sort) {
String column = SORT_COLUMNS.getOrDefault(sort, "created_at");
return db.sql("SELECT id, title, author FROM books ORDER BY " + column)
.query(Book.class)
.list();
}
// SEPARATE method for one book. No optional toggle parameter.
public Optional<Book> findBookById(long id) {
return db.sql("SELECT id, title, author FROM books WHERE id = ?")
.param(id)
.query(Book.class)
.optional();
}
}08The Full Lifecycle, End to End
Putting the layers together, here is the complete round trip for a single API call, say GET /books:
-
Binding
The handler extracts data from the request object and deserializes it into the native format.
-
Validation & Transformation
The handler validates the data and injects defaults / reshapes as needed.
-
Service call
The handler delegates to the service, which performs the real processing, calling the repository for any database work.
-
Result returns
The repository returns rows to the service; the service returns a clean result to the handler.
-
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
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.
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 ===
})
}# 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// Express middleware is the shape the whole idea is named after:
// (req, res, next). Calling next() passes execution along the chain.
function loggingMiddleware(req, res, next) {
console.log(`${req.method} ${req.path}`) // do work
// EARLY EXIT example (short-circuit, never calls next):
if (req.get('X-Blocked') === 'yes') {
return res.status(403).send('forbidden') // request stops here
}
next() // === next(): continue the chain ===
}
app.use(loggingMiddleware)import type { NextFunction, Request, Response } from 'express'
// The three parameters, spelled out by their types: the request, the
// response, and the `next` callable that invokes the rest of the chain.
// The `void` return is the reminder that a middleware communicates by
// calling next() or by writing a response, never by returning a value.
function loggingMiddleware(req: Request, res: Response, next: NextFunction): void {
console.log(`${req.method} ${req.path}`) // do work
// EARLY EXIT example (short-circuit, never calls next):
if (req.get('X-Blocked') === 'yes') {
res.status(403).send('forbidden') // request stops here
return
}
next() // === next(): continue the chain ===
}
app.use(loggingMiddleware)// A servlet Filter is Java's middleware. It receives the request, the
// response, and a FilterChain, and chain.doFilter IS next(): pass
// execution to the next filter, or on to the controller.
@Component
public class LoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
log.info("{} {}", req.getMethod(), req.getRequestURI()); // do work
// EARLY EXIT example (short-circuit, never calls the chain):
if ("yes".equals(req.getHeader("X-Blocked"))) {
res.sendError(HttpServletResponse.SC_FORBIDDEN, "forbidden");
return; // request stops here
}
chain.doFilter(req, res); // === 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))
})
}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 wrapperconst ALLOWED_ORIGIN = 'https://app.example.com'
function corsMiddleware(req, res, next) {
const origin = req.get('Origin') // runtime gives us this
if (origin === ALLOWED_ORIGIN) {
res.set('Access-Control-Allow-Origin', origin)
}
next() // pass along; browser blocks if header absent
}
function authMiddleware(req, res, next) {
const token = req.get('Authorization')
let claims
try {
claims = verifyToken(token)
} catch {
return res.status(401).send('unauthorized') // 401, stop
}
// SUCCESS: stash identity in the request context, then continue.
req.context = { ...req.context, userId: claims.userId, role: claims.role }
next()
}import type { NextFunction, Request, Response } from 'express'
const ALLOWED_ORIGIN = 'https://app.example.com'
export function corsMiddleware(req: Request, res: Response, next: NextFunction): void {
const origin = req.get('Origin') // runtime gives us this
if (origin === ALLOWED_ORIGIN) {
res.set('Access-Control-Allow-Origin', origin)
}
next() // pass along; browser blocks if header absent
}
export function authMiddleware(req: Request, res: Response, next: NextFunction): void {
const token = req.get('Authorization')
let claims: TokenClaims
try {
claims = verifyToken(token)
} catch {
res.status(401).send('unauthorized') // 401, stop
return
}
// SUCCESS: stash identity in the request context, then continue.
// `req.context` is typed once via declaration merging (next section),
// so every handler downstream reads it without a cast.
req.context = { ...req.context, userId: claims.userId, role: claims.role }
next()
}@Component
public class CorsFilter extends OncePerRequestFilter {
private static final String ALLOWED_ORIGIN = "https://app.example.com";
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String origin = req.getHeader("Origin"); // runtime gives us this
if (ALLOWED_ORIGIN.equals(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
chain.doFilter(req, res); // pass along; browser blocks if header absent
}
}
@Component
public class AuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String token = req.getHeader("Authorization");
TokenClaims claims;
try {
claims = verifyToken(token);
} catch (InvalidTokenException e) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "unauthorized"); // 401, stop
return;
}
// SUCCESS: stash identity in the request context, then continue.
// Request attributes ARE the servlet request context: a key-value
// map that lives and dies with this one request.
req.setAttribute("userId", claims.userId());
req.setAttribute("role", claims.role());
chain.doFilter(req, res);
}
}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.
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)
}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), 201app.post('/books', async (req, res) => {
const { title, author } = req.body ?? {}
// Read the trusted user id FROM THE CONTEXT, not from the body.
// The auth middleware put it there after verifying the token.
const { userId, role } = req.context
if (role !== 'admin' && role !== 'user') {
return res.status(403).send('forbidden')
}
// Persist with the SERVER-VERIFIED owner id, never the client's.
const book = await bookService.create({ title, author }, userId)
res.status(201).json(book) // 201
})import type { Request, Response } from 'express'
// Declare the context's shape ONCE and every handler in the codebase
// sees it. This is how you stop `req.context` from decaying into an
// `any` bag that each handler guesses at.
declare module 'express-serve-static-core' {
interface Request {
context: { userId: number; role: string; requestId: string }
}
}
export async function createBookHandler(req: Request, res: Response): Promise<void> {
const body = req.body as CreateBookRequest
// Read the trusted user id FROM THE CONTEXT, not from the body.
// The auth middleware put it there after verifying the token.
const { userId, role } = req.context
if (role !== 'admin' && role !== 'user') {
res.status(403).send('forbidden')
return
}
// Persist with the SERVER-VERIFIED owner id, never the client's.
const book = await bookService.create(body, userId)
res.status(201).json(book) // 201
}@PostMapping("/books")
ResponseEntity<?> createBook(@RequestBody CreateBookRequest req,
HttpServletRequest http) {
// Read the trusted user id FROM THE REQUEST CONTEXT, not from req.
// The auth filter put it there after verifying the token.
long userId = (long) http.getAttribute("userId");
String role = (String) http.getAttribute("role");
if (!role.equals("admin") && !role.equals("user")) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body("forbidden");
}
// Persist with the SERVER-VERIFIED owner id, never the client's.
Book book = bookService.create(req, userId);
return ResponseEntity.status(HttpStatus.CREATED).body(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 wrapperimport { randomUUID } from 'node:crypto'
function requestIdMiddleware(req, res, next) {
const id = randomUUID() // one unique id for this request
req.context = { ...req.context, requestId: id }
res.set('X-Request-ID', id) // echo it back / forward it
console.log(`[${id}] ${req.method} ${req.path}`)
next()
}import { randomUUID } from 'node:crypto'
import type { NextFunction, Request, Response } from 'express'
export function requestIdMiddleware(req: Request, res: Response, next: NextFunction): void {
const id = randomUUID() // one unique id for this request
req.context = { ...req.context, requestId: id }
res.set('X-Request-ID', id) // echo it back / forward it
console.log(`[${id}] ${req.method} ${req.path}`)
// Node also ships AsyncLocalStorage, which carries the id through
// awaits into code that never sees `req` at all, the logger, the
// database layer, an outbound fetch. Same context, no hand-off.
next()
}@Component
public class RequestIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain)
throws ServletException, IOException {
String id = UUID.randomUUID().toString(); // one unique id for this request
req.setAttribute("requestId", id);
res.setHeader("X-Request-ID", id); // echo it back / forward it
// MDC is the logging framework's own per-request context. Put the
// id in once and EVERY log line from this request carries it, with
// no logging call having to pass it along by hand.
MDC.put("requestId", id);
try {
log.info("{} {}", req.getMethod(), req.getRequestURI());
chain.doFilter(req, res);
} finally {
MDC.clear(); // threads are pooled, never leak it to the next request
}
}
}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).