open atlas
↑ Back to track
Go, zero to senior GO · 03 · 02

Middleware patterns: chaining, ordering, context, and the ResponseWriter wrapping traps

Middleware is func(http.Handler) http.Handler composed outside-in: logging outside recovery outside auth. Request-scoped data rides the context with unexported typed keys. Wrapping ResponseWriter to capture status risks double WriteHeader and silently losing Flusher and Hijacker.

GO Middle ◷ 18 min
Level
FoundationsJuniorMiddleSenior

The dashboards said the deploy was perfect: error rate 0.0%, every response a 200. The support queue said otherwise — customers were getting blank pages. It took an hour to find the gap: the team had shipped a new logging middleware that wrapped ResponseWriter to record the status code, defaulting the field to 200 and updating it in WriteHeader. But the panic-recovery middleware sat outside the logger, so when a handler panicked, recovery wrote its 500 to the raw writer — the logger’s wrapper never saw it and reported its default. Same week, a second casualty surfaced: the server-sent-events endpoint stopped streaming. The wrapper struct didn’t implement Flusher, so the SSE handler’s type assertion w.(http.Flusher) quietly failed and events buffered until the connection died. Two outages, one root cause: a ResponseWriter wrapper is not transparent, and middleware order is not a style choice — both are semantics you have to design.

The shape: a function that eats a Handler and returns one

Go has no middleware framework because the Handler interface already is one. A middleware is any function with this signature:

func Logging(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		start := time.Now()
		next.ServeHTTP(w, r) // call inward
		slog.Info("request", "method", r.Method, "path", r.URL.Path,
			"dur", time.Since(start))
	})
}

// Composition is just function application, innermost first:
handler := Logging(Recover(Auth(mux)))

The closure runs code before and after next.ServeHTTP — that is the entire mechanism. Because ServeMux is itself a Handler, the same middleware wraps one route or the whole router. Chains read inside-out: Logging(Recover(Auth(mux))) means a request passes Logging → Recover → Auth → mux, and the response unwinds back through them in reverse. Teams that dislike the nested-call pyramid write a ten-line Chain(mux, Auth, Recover, Logging) helper that folds the slice — same semantics, flatter code. Resist importing a middleware framework for this; the function composition is the feature, and every third-party middleware that takes http.Handler plugs straight in.

Ordering is policy, not preference

When you see a mysterious 200 in your access log while a customer reports a blank page, the first thing to audit is middleware order — not the handler code.

Each layer can only observe layers inside it and protect layers inside it. That single rule dictates the canonical order:

  • Logging outermost — it must see every request, including ones auth rejects, and record the status recovery writes. Put it inside auth and your access log goes blind to 401s.
  • Recovery outside auth and business middleware — a panic in any inner layer must become a 500 plus a stack trace in the log, not a killed connection. (net/http’s built-in recover only closes the connection; the client sees a reset, not a status.)
  • Auth before anything that does work — rate limiting and body parsing for an unauthenticated caller is paying for an attacker’s traffic.

The Hook’s first outage is the ordering rule violated: recovery sat outside logging, so recovery’s 500 was written to the raw writer where the logger could not see it. Flip them — Logging(Recover(...)) — and the logger’s wrapper is what recovery writes through. There is no universally correct total order; there is a correct order relative to what each layer must observe, and you should be able to justify every position in your chain with a sentence.

Quiz

A chain is built as Recover(Logging(mux)) — recovery outermost, then logging, then the router. A handler panics. What does the access log record for that request?

Request-scoped data: context with typed keys

Auth middleware authenticates; the handler three layers in needs the user. The channel between them is the request’s context:

type ctxKey struct{} // unexported type: no other package can collide

func WithUser(ctx context.Context, u *User) context.Context {
	return context.WithValue(ctx, ctxKey{}, u)
}

func UserFrom(ctx context.Context) (*User, bool) {
	u, ok := ctx.Value(ctxKey{}).(*User)
	return u, ok
}

// middleware:   r = r.WithContext(WithUser(r.Context(), user))
// handler:      u, ok := UserFrom(r.Context())

Three rules keep this from rotting. Use an unexported key type, never a string — string keys collide across packages silently. Wrap access in typed helpers so the any-typed Value API never leaks into handlers. And keep context values to request-scoped facts a generic layer attaches: user identity, trace ID, deadline. The moment a value is a required input to business logic, it belongs in a function parameter — context values are invisible in signatures, unchecked by the compiler, and a nil assertion away from a panic. The honest cost: each WithValue is an allocation and lookup is a linear walk up the chain — irrelevant at four values, real at forty.

Wrapping ResponseWriter, and the two traps

To log the status code you must wrap, because ResponseWriter offers no getter:

type statusWriter struct {
	http.ResponseWriter
	status int
	wrote  bool
}

func (sw *statusWriter) WriteHeader(code int) {
	if sw.wrote { return } // swallow the second call: net/http logs
	sw.status, sw.wrote = code, true // "superfluous WriteHeader" otherwise
	sw.ResponseWriter.WriteHeader(code)
}

func (sw *statusWriter) Write(b []byte) (int, error) {
	if !sw.wrote { sw.WriteHeader(http.StatusOK) } // implicit 200 path
	return sw.ResponseWriter.Write(b)
}

Trap one is WriteHeader called twice: a handler writes 200, then an error path writes 500 — the second call is a no-op on the wire (headers already sent) and net/http logs superfluous response.WriteHeader. Your wrapper must model that, including the implicit WriteHeader(200) that the first Write triggers. Trap two is interface erasure: the embedded field forwards the three ResponseWriter methods, but the concrete writer underneath also implements http.Flusher (streaming), http.Hijacker (websockets take the raw conn), and supports io.ReaderFrom (the sendfile path that makes http.ServeContent fast). Your wrapper implements none of them, so every w.(http.Flusher) assertion downstream now fails — the Hook’s dead SSE endpoint. Either implement and forward the extras you need, or use http.NewResponseController(w) (Go 1.20+), which unwraps layers to find flush and hijack support — that, plus exposing an Unwrap() http.ResponseWriter method on your wrapper, is the modern contract.

Quiz

After deploying a status-capturing ResponseWriter wrapper, the websocket upgrade endpoint starts failing while plain JSON endpoints work fine. What is the most likely mechanism?

Why this works

Why did the stdlib add http.NewResponseController instead of making wrappers illegal or automatic? Because both extremes lose: forbidding wrappers kills the entire middleware ecosystem, and auto-forwarding optional interfaces is impossible — a wrapper type either has a Flush method or it does not, and Go interfaces are satisfied at compile time. The controller is the escape hatch that walks Unwrap chains at runtime asking each layer for the capability, so a logging wrapper that knows nothing about flushing no longer breaks streaming three layers down — as long as every wrapper in the chain plays the Unwrap convention.

Recall before you leave
  1. 01
    Justify the canonical middleware order — logging, recovery, auth, router — using the observation rule.
  2. 02
    List the traps in wrapping http.ResponseWriter for status capture and the modern mitigations.
Recap

Go middleware is the Handler interface used twice: a middleware takes the next Handler and returns a new one, running code before and after next.ServeHTTP inside a closure. Chains compose by nesting — Logging(Recover(Auth(mux))) — and read outside-in, with the response unwinding back through every layer. Order is semantics, derived from one rule: a layer observes only what it wraps. Logging outermost records auth rejections and recovery’s 500s through its own wrapper; recovery outside business layers converts panics into logged 500s instead of bare connection resets; auth runs before anything that costs money. Request-scoped facts travel in the context: unexported key types prevent cross-package collisions, typed helpers keep the untyped Value API contained, and anything business logic requires belongs in a parameter, not a context value — WithValue allocates and lookups walk a chain. Status capture demands wrapping ResponseWriter, which brings two traps: WriteHeader can effectively fire twice (including the implicit 200 on first Write), so the wrapper tracks write state; and embedding erases the underlying writer’s Flusher, Hijacker, and ReaderFrom, breaking SSE, websockets, and the sendfile path for any downstream type assertion. Forward what you need, expose Unwrap, and use http.NewResponseController to reach capabilities through wrapper chains. Now when you see a 200 in the access log while the client got a 500, check which layer is outside which — the answer is almost always middleware ordering.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 6 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources2
expand
  1. 01
  2. 02

Trademarks belong to their respective owners. Editorial reference only.