JavaRush /Courses /Go SELF /Middleware chaining and application order

Middleware chaining and application order

Go SELF
Level 62, Lesson 0
Available

1. Introduction

If you build a server “straight on,” cross-cutting requirements show up almost immediately: add a header to all responses, log every request, check access, rate-limit, catch panics. And suddenly you discover that the same code is duplicated in every handler. At some point you change one rule (for example, the error format) and start looking for “17 more places where this needs fixing.”

Middleware solves this problem the same way the good habit of washing your hands solves the problem of “why am I sick again”: it’s not magic, it’s consistency.

The key idea: a handler deals with business logic, and middleware handles cross-cutting policy around that logic.

Contract in net/http: http.Handler as the wrapping point

Before building chains, it’s important to look again at the contract we’ll be orbiting around. In net/http, a handler is any type that has a method ServeHTTP(w, r). That’s exactly why adapters and wrappers are easy to write in Go: you simply return another http.Handler that calls the next one inside. It’s the same “adapter pattern,” just without the big words.

Let’s recall the minimal server skeleton (highly simplified):

package main

import (
	"log"
	"net/http"
)

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})

	log.Fatal(http.ListenAndServe(":8080", mux))
}

Almost everything interesting in the server’s further architecture revolves around what exactly we pass as the second argument to ListenAndServe.

Middleware as a wrapper function

When you first hear “middleware,” you might want to ask: “Is this a library? A framework? A religion?” In reality it’s just a function that takes one handler and returns another handler. That is, literally, a “wrapper” around http.Handler.

The most common signature looks like this:

func(next http.Handler) http.Handler

To make the code easier to read, people usually introduce a named type:

package httpmw

import "net/http"

type Middleware func(http.Handler) http.Handler

After that, any middleware reads as “take the next layer (next) and return a new layer.”

2. Middleware mechanics: “before → next → after”

Almost any middleware internally looks like “do something before the request,” then pass control down, and “do something after.” It’s convenient to picture this as an onion: the outer layer sees the request first, and the response last.

Let’s build a minimal middleware that adds a header to all responses. (Yes, it’s simple, but it’s perfect for understanding the mechanics.)

package httpmw

import "net/http"

func WithHeader(name, value string) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set(name, value) // "before"
			next.ServeHTTP(w, r)        // "pass down"
		})
	}
}

Note: we use http.HandlerFunc because it’s the adapter for “a function as a handler.” That means we don’t have to declare a separate type X struct{} just for ServeHTTP.

Short-circuit: middleware can stop a request

Beginners often have an inner feeling that next.ServeHTTP(...) must always be called, otherwise “how will it continue?” But the point of some middleware is precisely to not let the request proceed: access checks, limits, maintenance mode.

Let’s make a “server under maintenance” example: if the flag is enabled, we respond immediately.

package httpmw

import "net/http"

func MaintenanceMode(enabled bool) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if enabled {
				w.WriteHeader(http.StatusServiceUnavailable)
				return // short-circuit: do not go any deeper
			}
			next.ServeHTTP(w, r)
		})
	}
}

The main takeaway is: middleware is allowed to complete the request on its own. This isn’t a “hack,” it’s a core tool for building policies.

4. Middleware chain and application order

When there is more than one middleware, we want to apply them all to a single mux (or to a single handler). And that raises an important question: in what order are they applied?

If we have:

  • A — middleware #1
  • B — middleware #2
  • C — middleware #3
  • H — the final handler (for example, mux)

then the logical “onion” usually looks like this:

A(B(C(H)))

That is, A is the outermost layer: it sees the request first and “escorts” the response last.

A diagram helps to understand the order:

flowchart TD
    R[HTTP request] --> A[Middleware A]
    A --> B[Middleware B]
    B --> C[Middleware C]
    C --> H["Final handler (mux)"]
    H --> C
    C --> B
    B --> A
    A --> W[HTTP response]

Here’s the important detail: “inbound” we go A → B → C → H, and “outbound” (after the handler has finished) we go H → C → B → A. That’s why middleware order affects even things like status logging or panic recovery: the outer layer “sees everything” that happened inside.

Function Chain: assembling a chain with a readable order

Now we want an approach where the order is:

  • clearly visible in code,
  • always assembled the same way (not “one way here, another way there”),
  • doesn’t require manually nesting A(B(C(H))), because it’s painful even to look at.

Let’s make a Chain helper. The key trick: apply middleware in reverse order, so the first in the list becomes the outermost.

package httpmw

import "net/http"

type Middleware func(http.Handler) http.Handler

func Chain(h http.Handler, mws ...Middleware) http.Handler {
	for i := len(mws) - 1; i >= 0; i-- {
		h = mws[i](h)
	}
	return h
}

And this is the “magic without magic”: the list reads left to right (as we’re used to), but it’s applied right to left so the result is mws[0](mws[1](...h)).

Who runs first: a mini table

To avoid getting completely tangled (you will, it’s tradition), here’s a simple table. Suppose:

h := Chain(mux, A, B, C)

Then:

Entity Who sees the request first Who calls next first Who “closes” the response last
A
yes yes yes
B
no after
A
before
A
C
no after
B
before
B
mux
no the innermost the innermost

If you feel a bit dizzy, that’s normal. Middleware order initially feels like “headphone wires”: it seems like you did nothing, and somehow it’s still tangled.

“Manual matryoshka” vs Chain

To be able to read other people’s code, it’s useful to see both variants. Sometimes you’ll run into something like:

h := A(B(C(mux)))

It’s honest, simple, and it works. But a week later A(B(C(D(E(F(...))))) looks like a cafe Wi‑Fi password: there are symbols, but not much joy.

With Chain, the same thing becomes:

h := Chain(mux, A, B, C)

And this reads as “A on the outside, then B, then C.” That’s exactly what we want.

5. Wiring chaining into the app without rewriting the server

Until now, we’ve been passing our mux into ListenAndServe. Now we’ll pass a wrapped mux. And this is an important architectural win: we change the behavior of the entire server without touching the endpoints themselves.

The skeleton of main.go (simplified, but essentially correct):

package main

import (
	"log"
	"net/http"
	"time"

	"example.com/taskapi/httpmw"
)

func main() {
	mux := http.NewServeMux()
	registerRoutes(mux)

	h := httpmw.Chain(mux,
		httpmw.WithHeader("Content-Type", "application/json; charset=utf-8"),
		httpmw.MaintenanceMode(false),
		httpmw.WithTimeoutHint(2*time.Second),
	)

	log.Fatal(http.ListenAndServe(":8080", h))
}

Here registerRoutes(mux) is your usual endpoint registration code (/api/v1/tasks, /api/v1/tasks/{id}, etc.), which we don’t want to touch. And WithTimeoutHint is another simple middleware that we’ll write now, to show an example of a “parameterized” wrapper.

Example of middleware with a parameter: WithTimeoutHint

We want to show middleware that takes a parameter and does something useful, without pulling in complex topics. Headers are perfect for this.

Suppose we want to add a “hint” to the client: the server expects requests to be fast (this is not a real timeout, just a demonstration of “cross-cutting behavior”).

package httpmw

import (
	"net/http"
	"time"
)

func WithTimeoutHint(d time.Duration) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			w.Header().Set("X-Timeout-Hint", d.String())
			next.ServeHTTP(w, r)
		})
	}
}

The plus of this example is that it shows “middleware as a factory”: you set the parameter once when assembling the server, and then the same rule applies for every request.

Why it’s better to assemble the chain in one place

When the middleware chain is assembled in one place (usually in main), you get almost free benefits.

First, you can read the order with your eyes like the server’s contract: top to bottom, the policies that apply to all requests are listed. Second, if something breaks in the logic (for example, headers suddenly disappear or the server stops responding to some requests), you check one file instead of running around dozens of handlers.

And finally, it enforces discipline: one middleware, one responsibility. When you start mixing headers, auth, logging, and panic handling into one wrapper, you’re back to the same “spaghetti,” just served with a trendier sauce.

6. A nuance about Context: how middleware passes data down

We won’t dive deep into storing data in context right now (that’s a separate big topic), but it’s important to note: middleware often not only sets headers, but also adds something to r.Context() and passes it further down via r.WithContext(...).

Why is this possible at all? Because http.Request already contains a Context, and the standard library expects you to respect the lifetime of a request through context (cancellation, deadlines, metadata).

For now, just remember this: if middleware “created a new context,” you must put it back into r, otherwise the lower layers won’t see the changes.

7. Typical mistakes with chaining and middleware order

Mistake #1: wrong expectation about the order (“why doesn’t logging see what I wanted?”).
A very common confusion: you put middleware first in the list, but expect it to “run after everything.” In fact, the first in the list is usually the outermost: it sees the request first and completes the response last. If you need to “run closer to the handler,” the layer must be deeper (further to the right in the list).

Mistake #2: you forgot to call next.ServeHTTP(w, r) where the request should be allowed to continue.
This is a classic: you wrote middleware, checked one case, everything seems fine. Then it turns out the server only replies with 401/403/something else, and “normal” requests simply don’t pass through. The cure is discipline: keep the “before → next → after” frame in mind and explicitly verify all return branches.

Mistake #3: an accidental double call to next.ServeHTTP.
Sometimes beginners try to do a “retry” or mistakenly call next both inside an if and after the if. Result: the handler writes to the ResponseWriter twice, you get strange responses, and sometimes panics. Almost always, middleware should call next exactly once (or zero times, in case of short-circuit).

Mistake #4: writing headers too late.
Headers must be set before anything below starts writing the response body or calls WriteHeader. If the inner handler already wrote the body, then “changing the header afterwards” is like trying to rewrite the credits after the movie has already premiered in theaters: in theory you might want to, but in practice it’s already too late.

Mistake #5: assembling the chain “in different places” and order drift.
First you wrap mux in one file, then some handler wraps another handler “locally,” then tests introduce a third scheme, and you lose track of what is a global policy and what is a local exception. A good rule: define the global middleware order once at the application boundary, and use local wrappers only if you understand very clearly why.

1
Task
Go SELF, level 62, lesson 0
Locked
Outgoing header
Outgoing header
1
Task
Go SELF, level 62, lesson 0
Locked
Onion chain
Onion chain
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION