Decorator Pattern — Middle¶
1. What this level adds¶
Junior taught the shape: a wrapper that implements the same interface, holds an Inner, does work around the delegated call. Middle is about picking the right variant and avoiding the production bugs:
- Function-style middleware vs struct-style decorators — when each fits.
- Generic decorators (Go 1.18+) and where they pay off.
- Ordering invariants and what to do when middleware must run in a specific order.
- Recovering from panics inside middleware safely.
- Stateful decorators — counters, rate-limit windows, circuit breakers.
- Testing decorators — isolation, fakes, observability of the chain.
- Performance — interface dispatch, closure capture, escape analysis at the decorator boundary.
By the end you should be able to design a middleware stack for a production HTTP server or a decorator chain for a gateway without surprises.
2. Table of Contents¶
- What this level adds
- Table of Contents
- Struct vs function decorators
- Generic decorators
- The middleware chain — formalized
- Ordering invariants and why they matter
- Recovering from panics
- Stateful decorators
- Embedding-based decorators — when and how
- Testing decorators
- Decorator and context.Context
- Coding patterns
- Performance notes
- Common middle-level mistakes
- Debugging a decorator chain
- Tricky points
- Test
- Cheat sheet
- Summary
3. Struct vs function decorators¶
Two shapes for the same idea. Each fits a different situation.
3.1 The struct decorator¶
type RetryingCharger struct {
Inner Charger
Attempts int
}
func (r *RetryingCharger) Charge(ctx context.Context, amount int) error {
var err error
for i := 0; i < r.Attempts; i++ {
if err = r.Inner.Charge(ctx, amount); err == nil { return nil }
}
return err
}
// Usage:
c := &RetryingCharger{Inner: &StripeGateway{...}, Attempts: 3}
Best when: - The decorator has state (retry counter, cache, rate-limit budget, metrics counter). - The decorator is configured at construction — attempts count, TTL, logger. - You want the constructor's signature to enforce required configuration (no nil logger).
3.2 The function decorator (middleware)¶
func Retrying(attempts int) func(Charger) Charger {
return func(inner Charger) Charger {
return ChargerFunc(func(ctx context.Context, amount int) error {
var err error
for i := 0; i < attempts; i++ {
if err = inner.Charge(ctx, amount); err == nil { return nil }
}
return err
})
}
}
// Usage:
c := Retrying(3)(&StripeGateway{...})
Best when: - The decorator has no state (or state is captured by the closure). - You're composing many decorators in a chain. - You want to defer configuration to the chain-builder.
The middleware signature in HTTP is the canonical case: func(http.Handler) http.Handler. It's a function decorator returned by a configuration function.
3.3 Which to choose¶
| Signal | Lean toward |
|---|---|
| The decorator holds state shared across calls (counters, cache, breakers) | Struct |
| The decorator is stateless — wrap and forward | Function |
| You're publishing the API to other packages, and consumers will write their own decorators | Either — pick the one easier to implement, since users mirror your choice |
| You expect the decorator to grow new methods later | Struct (struct decorators evolve; function shapes are frozen) |
| Hot-path with many short calls | Either — measure if you suspect a problem |
| Closure capture would allocate a per-call instance | Struct (one allocation at construction time) |
For HTTP middleware: function. For everything else: usually struct.
4. Generic decorators¶
Go 1.18+ generics let you write a decorator over an arbitrary type parameter. Useful, with caveats.
4.1 The simple wrapper¶
This just stores a T. It's not a decorator yet because there's no behaviour to wrap. The pattern only earns its keep when T has a constraint that exposes methods to call.
4.2 The constrained wrapper¶
type Logger interface { Log(string) }
type Counted[L Logger] struct {
Inner L
n atomic.Int64
}
func (c *Counted[L]) Log(msg string) {
c.n.Add(1)
c.Inner.Log(msg)
}
func (c *Counted[L]) Count() int64 { return c.n.Load() }
Now Counted[L] is a decorator over any L satisfying Logger. Calling .Inner returns the concrete type L (not the interface), so you can recover original-type methods that interface dispatch would hide.
4.3 Generic middleware¶
type Middleware[T any] func(next T) T
func Compose[T any](base T, middlewares ...Middleware[T]) T {
for i := len(middlewares) - 1; i >= 0; i-- {
base = middlewares[i](base)
}
return base
}
A generic Compose works for any type T for which you've defined middleware functions. The catch: T itself must support the operation you want to decorate, and Go generics don't let you constrain "T has a method called Foo" without an interface — so usually T ends up being an interface anyway, and the generics save very little.
4.4 When to bother¶
Almost never for application code. Generic decorators show up in library infrastructure: - Compose[T], Pipe[T], Tap[T] helpers in a utility package. - Cache wrappers like Cache[K, V] where the key/value types vary. - Testing helpers that decorate any handler-like value.
If you're writing one decorator for one interface, plain interface-based decorators are clearer.
5. The middleware chain — formalized¶
The middleware chain in junior §5 is one of the most-used Go idioms. Worth formalising.
type Middleware func(next http.Handler) http.Handler
// Chain applies middlewares left-to-right: Chain(h, A, B, C)
// is equivalent to A(B(C(h))) — A runs first.
func Chain(h http.Handler, mw ...Middleware) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
Why iterate in reverse: when you compose A(B(C(h))), the outermost is A. To build this from a slice [A, B, C], start from the inside (C) and wrap outward. The reverse loop achieves that.
5.1 Per-route vs global chains¶
// Global — all routes get logging + recovery + tracing
globalChain := func(h http.Handler) http.Handler {
return Chain(h, Tracing, Recovery, Logging)
}
mux := http.NewServeMux()
mux.Handle("/api", globalChain(apiHandler))
mux.Handle("/admin", globalChain(Auth(adminHandler))) // /admin also requires auth
The convention popularised by chi and gorilla/mux: middlewares can be attached globally (to the whole router), per-group (to a sub-router), or per-route. Each level wraps the previous.
5.2 Don't compose at runtime¶
// Anti-idiom — building the chain per request
func handle(w http.ResponseWriter, r *http.Request) {
Chain(actualHandler, Logging, Auth).ServeHTTP(w, r) // chain built every request
}
Closures and chain construction allocate. Doing this per request adds ~100 ns and a few allocs per call — not catastrophic, but pointless. Build the chain once at startup; serve it many times.
// Correct — chain built once
chain := Chain(actualHandler, Logging, Auth)
func handle(w http.ResponseWriter, r *http.Request) {
chain.ServeHTTP(w, r)
}
5.3 The "before" / "after" split¶
A common request: "I want logging to print the start time and the duration". The decorator must capture state across the call:
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() // before
next.ServeHTTP(w, r)
log.Printf("%s %s took %s", r.Method, r.URL.Path, time.Since(start)) // after
})
}
The local variable start lives across the call. No state needs to be on the decorator struct — it's on the stack, captured by the closure that the inner Handler runs through.
6. Ordering invariants and why they matter¶
Middlewares often have order requirements that aren't obvious from the code.
6.1 Recovery before tracing? Or tracing before recovery?¶
// Option A — Tracing outermost
h := Tracing(Recovery(Auth(handleAPI)))
// Option B — Recovery outermost
h := Recovery(Tracing(Auth(handleAPI)))
Option A: if handleAPI panics, Recovery catches it, the trace records the failure, the tracing span closes normally. Good.
Option B: if handleAPI panics, Recovery catches it before Tracing knows the span is finished. The trace span might leak or report incorrect duration. Probably bad.
The rule: observability decorators go outside resilience decorators. Tracing wants to see everything, including the recovered panic. Recovery should be inside, so the trace finishes properly.
6.2 Auth before logging? Or after?¶
Two reasonable answers, depending on what you want to log:
- Log every request, including unauthorised ones (security audit) — Auth inside Logging:
Logging(Auth(handler)). Logging sees 401 responses. - Log only authenticated traffic — Auth outside Logging:
Auth(Logging(handler)). Unauthorized requests are rejected before logging runs.
Pick deliberately, document the choice, and don't let casual reorders break it.
6.3 Rate limiting outside auth — usually¶
Rate-limit first, then auth. Otherwise an attacker pummelling unauthenticated requests forces your auth code to run on every attempt. Rate-limit on something cheap (IP, API key prefix) before doing real work.
6.4 Defensive ordering checks¶
In a large codebase, document the chain order as code:
const (
OrderTrace = 0
OrderRecovery = 100
OrderRateLimit = 200
OrderAuth = 300
OrderAuthorize = 400
OrderLogging = 500
OrderHandler = 1000
)
type MiddlewareSpec struct {
Order int
Fn Middleware
}
func BuildChain(handler http.Handler, mws ...MiddlewareSpec) http.Handler {
sort.Slice(mws, func(i, j int) bool { return mws[i].Order < mws[j].Order })
for i := len(mws) - 1; i >= 0; i-- {
handler = mws[i].Fn(handler)
}
return handler
}
Order numbers make the chain self-documenting. Used in some larger projects (Knative, Istio); overkill for small apps. Worth knowing exists.
7. Recovering from panics¶
Recovery middleware is the canonical example of a defensive decorator. Get it right:
func Recovery(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
// Log with stack trace
log.Printf("panic: %v\n%s", rec, debug.Stack())
// Try to write a 500, but only if we haven't already started writing
if !headersSent(w) {
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}
}()
next.ServeHTTP(w, r)
})
}
Three subtleties:
7.1 Recover only catches panics from this goroutine¶
If next.ServeHTTP spawns a goroutine and that goroutine panics, this recovery won't catch it. The runtime kills the process. If your handler spawns goroutines, each goroutine needs its own recovery — or, better, the handler shouldn't spawn goroutines without joining them.
7.2 Don't write twice¶
// Anti-idiom
defer func() {
if rec := recover(); rec != nil {
http.Error(w, "internal error", 500) // might be too late
}
}()
w.WriteHeader(200)
panic("...")
If the handler already wrote a status code, calling http.Error panics or no-ops depending on the writer. The check headersSent (which doesn't exist in stdlib but you can implement via a wrapper around ResponseWriter) prevents this. In practice many recovery middlewares just don't write at all on panic — they log, and let the client see whatever partial response was already sent.
7.3 The runtime.Goexit case¶
runtime.Goexit() and t.FailNow() from *testing.T unwind the stack via panic-like mechanics but recover() doesn't catch them. Your defer fires, but the value returned by recover() is nil. Don't treat "no panic" as "no error" in recovery code — log unconditionally if the handler returned abnormally.
8. Stateful decorators¶
Stateless decorators (logging, tracing) are easy. Stateful ones (rate limit, circuit breaker, cache) need care around concurrency.
8.1 Rate limiter¶
type RateLimit struct {
Inner Charger
limiter *rate.Limiter
}
func NewRateLimit(inner Charger, rps int, burst int) *RateLimit {
return &RateLimit{
Inner: inner,
limiter: rate.NewLimiter(rate.Limit(rps), burst),
}
}
func (r *RateLimit) Charge(ctx context.Context, amount int) error {
if err := r.limiter.Wait(ctx); err != nil {
return fmt.Errorf("RateLimit: %w", err)
}
return r.Inner.Charge(ctx, amount)
}
The rate.Limiter from golang.org/x/time/rate is thread-safe. The decorator delegates concurrency to it.
8.2 Circuit breaker¶
type Breaker struct {
Inner Charger
mu sync.Mutex
state BreakerState // closed, open, half-open
failures int
opened time.Time
}
func (b *Breaker) Charge(ctx context.Context, amount int) error {
if !b.allow() {
return errCircuitOpen
}
err := b.Inner.Charge(ctx, amount)
b.record(err)
return err
}
func (b *Breaker) allow() bool {
b.mu.Lock()
defer b.mu.Unlock()
switch b.state {
case BreakerOpen:
if time.Since(b.opened) > openTimeout {
b.state = BreakerHalfOpen
return true
}
return false
case BreakerHalfOpen:
return true
default:
return true
}
}
func (b *Breaker) record(err error) {
b.mu.Lock()
defer b.mu.Unlock()
if err != nil {
b.failures++
if b.failures >= failureThreshold {
b.state = BreakerOpen
b.opened = time.Now()
}
return
}
if b.state == BreakerHalfOpen {
b.state = BreakerClosed
}
b.failures = 0
}
Notice: the state machine is inside the decorator. The inner gateway has no idea breakers exist. The breaker's mutex is held during state transitions, not during the inner call.
8.3 Per-key state¶
Sometimes the decorator needs state per key (per user, per route, per gateway). Use sync.Map or a sharded map:
type PerKeyRateLimit struct {
Inner Charger
limiters sync.Map // map[string]*rate.Limiter
rps rate.Limit
burst int
}
func (p *PerKeyRateLimit) limiterFor(key string) *rate.Limiter {
if v, ok := p.limiters.Load(key); ok { return v.(*rate.Limiter) }
lim := rate.NewLimiter(p.rps, p.burst)
actual, _ := p.limiters.LoadOrStore(key, lim)
return actual.(*rate.Limiter)
}
LoadOrStore avoids the race where two goroutines both miss, both create a new limiter, and one overwrites the other. The wasted creation is OK because limiters are cheap.
9. Embedding-based decorators — when and how¶
Junior §4.3 mentioned this; here's the detail.
type Charger interface {
Charge(context.Context, int) error
Refund(context.Context, string) error
Status(context.Context, string) (string, error)
}
type LoggingCharger struct {
Charger // embed the interface
log *log.Logger
}
// Override only Charge; Refund and Status are promoted from the embedded Charger
func (l LoggingCharger) Charge(ctx context.Context, amount int) error {
l.log.Printf("Charge: %d", amount)
return l.Charger.Charge(ctx, amount)
}
Usage:
lc := LoggingCharger{Charger: &StripeGateway{...}, log: log.Default()}
lc.Charge(ctx, 100) // logged
lc.Refund(ctx, "id") // unchanged — calls StripeGateway.Refund directly
lc.Status(ctx, "id") // unchanged
Benefits¶
- You only write code for the methods you actually want to decorate. The rest are automatic.
- The
Innerfield name is the interface name — readable.
Costs¶
- The embedded interface is a public field by default (the field is named after the interface). Callers can mutate it:
lc.Charger = &PayPalGateway{}. If you want this hidden, define a named non-exported field and forward manually. - Method set rules are subtle. If you embed
Charger(interface), the wrapper has all methods. If you embed*StripeGateway(pointer), the wrapper has only methods on*StripeGateway's method set. Pick what you mean. - If the inner interface gains a new method, your wrapper automatically inherits it without decoration — sometimes good, sometimes bad. (Often a new method should be logged too — but no compile error reminds you.)
When to use¶
- The interface has many methods (5+).
- Most methods don't need decoration.
- You don't mind the public field.
For 1-3 method interfaces, plain struct decorators are clearer. Embedding shines for database/sql-style interfaces with a dozen methods.
10. Testing decorators¶
Three approaches, each useful for different situations.
10.1 Test the decorator with a fake inner¶
type fakeCharger struct {
chargeCalled bool
chargeReturn error
}
func (f *fakeCharger) Charge(ctx context.Context, amount int) error {
f.chargeCalled = true
return f.chargeReturn
}
func TestRetryingCharger(t *testing.T) {
f := &fakeCharger{chargeReturn: errors.New("transient")}
r := &RetryingCharger{Inner: f, Attempts: 3}
err := r.Charge(context.Background(), 100)
if err == nil { t.Fatal("expected error after exhausting retries") }
if !f.chargeCalled { t.Error("inner never called") }
}
Simple, no library. Test the decorator's behaviour in isolation.
10.2 Test that a decorator chain produces expected output¶
func TestChainOrder(t *testing.T) {
var calls []string
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, "handler")
})
logging := func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls = append(calls, "logging:before")
next.ServeHTTP(w, r)
calls = append(calls, "logging:after")
})
}
chain := logging(h)
chain.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil))
want := []string{"logging:before", "handler", "logging:after"}
if !reflect.DeepEqual(calls, want) {
t.Errorf("calls = %v, want %v", calls, want)
}
}
Verifies the chain runs in the expected order. Useful when you have complex ordering invariants (§6).
10.3 Test the decorator is transparent for non-decorated methods¶
func TestLoggingChargerForwardsRefund(t *testing.T) {
f := &fakeCharger{}
lc := LoggingCharger{Charger: f, log: log.New(io.Discard, "", 0)}
lc.Refund(context.Background(), "id_123")
if !f.refundCalled { t.Error("Refund not forwarded") }
}
Embedding-based decorators delegate by default. Make sure you assert on it — otherwise a refactor that accidentally drops the embedding breaks production silently.
11. Decorator and context.Context¶
Many decorators modify the context they pass to the inner call.
11.1 Adding a deadline¶
func Timeout(d time.Duration) Middleware {
return func(next Charger) Charger {
return ChargerFunc(func(ctx context.Context, amount int) error {
ctx, cancel := context.WithTimeout(ctx, d)
defer cancel()
return next.Charge(ctx, amount)
})
}
}
The inner gets a derived context with a timeout. Cancel propagates correctly via the defer.
11.2 Injecting a request ID¶
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := r.Header.Get("X-Request-ID")
if id == "" { id = uuid.NewString() }
ctx := context.WithValue(r.Context(), requestIDKey, id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Downstream handlers can extract the ID from r.Context(). The decorator is the right place to derive it because every request needs the same logic.
11.3 Watch out: the wrong context¶
// Anti-idiom
func Timeout(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) // BUG
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Deriving from context.Background() discards the request's context. If the parent had a deadline, it's gone. If the parent was cancelled, the inner doesn't see it. Always derive from r.Context() (or the equivalent caller-supplied context).
12. Coding patterns¶
12.1 The conditional decorator¶
func If(cond bool, mw Middleware) Middleware {
if !cond { return identity }
return mw
}
func identity(next http.Handler) http.Handler { return next }
chain := Chain(h,
If(useAuth, Auth),
If(debug, Logging),
Recovery,
)
Cleaner than if useAuth { chain = Auth(chain) }. The identity function is a no-op middleware — useful as a placeholder.
12.2 The named-step decorator¶
type Named struct {
Name string
Inner Charger
}
func (n *Named) Charge(ctx context.Context, amount int) error {
log.Printf("[%s] charge: %d", n.Name, amount)
return n.Inner.Charge(ctx, amount)
}
Adds a tag to logs/metrics for the wrapped chain segment. Useful in pipelines with multiple wrapping layers.
12.3 The conditional unwrap¶
type unwrappable interface{ Unwrap() Charger }
func base(c Charger) Charger {
for {
u, ok := c.(unwrappable)
if !ok { return c }
c = u.Unwrap()
}
}
Some decorators expose an Unwrap() method (mirroring errors.Unwrap). Lets callers reach the base implementation for type assertions or metrics. Use sparingly — it leaks abstraction.
12.4 The metrics decorator with histograms¶
type MetricsCharger struct {
Inner Charger
duration *prometheus.HistogramVec
}
func (m *MetricsCharger) Charge(ctx context.Context, amount int) (err error) {
start := time.Now()
defer func() {
status := "ok"
if err != nil { status = "error" }
m.duration.WithLabelValues(status).Observe(time.Since(start).Seconds())
}()
return m.Inner.Charge(ctx, amount)
}
The named return err lets the deferred function inspect the error after the call. A common decorator idiom for observing both success and failure paths.
13. Performance notes¶
Decorators add overhead. Measured on Go 1.22, amd64:
BenchmarkDirectCharge-8 500000000 2.10 ns/op 0 B/op 0 allocs/op
BenchmarkOneDecorator-8 300000000 3.41 ns/op 0 B/op 0 allocs/op
BenchmarkFiveDecorators-8 100000000 12.50 ns/op 0 B/op 0 allocs/op
BenchmarkMiddlewareChain5-8 80000000 14.20 ns/op 0 B/op 0 allocs/op
Each layer adds ~1-2 ns (an interface dispatch). For an HTTP server handling thousands of requests per second, even ten middlewares add ~20 ns per request — invisible against the ~100,000 ns of actual request work.
Where it does matter:
13.1 Hot inner loops¶
// In a tight loop, each call dispatches through three decorators
for _, item := range millionsOfItems {
err := c.Charge(ctx, item.Amount) // dispatch 3x
if err != nil { /* ... */ }
}
If c is decorated three times and called millions of times, that's a few milliseconds of dispatch overhead. Usually still dwarfed by whatever Charge actually does, but profile if you're suspicious.
13.2 Closure allocation in middleware setup¶
// Each call to Logging creates a new closure — allocation
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf(...)
next.ServeHTTP(w, r)
})
}
If you call Logging(h) once at startup, the closure cost is paid once. If you call it per request (don't!), the cost is per-request.
13.3 Interface boxing in generic decorators¶
// Each call boxes the result into an interface
func Trace[T any](next func() T) func() T {
return func() T {
log.Println("before")
v := next()
log.Println("after")
return v
}
}
The interior of generic functions can have hidden interface conversions during compilation. Profile or read -gcflags="-m" if performance matters.
14. Common middle-level mistakes¶
14.1 The leaky decorator¶
type LoggingCharger struct {
Inner Charger
log *log.Logger
}
func (l *LoggingCharger) ApiKey() string {
if sg, ok := l.Inner.(*StripeGateway); ok {
return sg.apiKey
}
return ""
}
The decorator now exposes implementation-specific methods (ApiKey()). Consumers must type-assert to *LoggingCharger to use them. Either expose the method on Charger (and require all implementations to support it), or don't expose it at all.
14.2 The order-sensitive constructor¶
If WithMiddleware appends to a slice and the chain is built from the slice, the order is sensitive. The caller has to know whether the first option runs first or last. Document it loudly — or use explicit ordering (§6.4).
14.3 Hidden state mutation through Inner¶
type CountingCharger struct {
Inner Charger
n int
}
func (c *CountingCharger) Charge(...) error {
c.n++
return c.Inner.Charge(...)
}
// In another goroutine:
go counting.Charge(...) // race on c.n
The decorator has unprotected mutable state. Fix with atomic.Int64 (lightweight counter) or a mutex (complex state).
14.4 Forgetting that errors propagate¶
func (l *LoggingCharger) Charge(...) (string, error) {
l.log.Printf("Charge")
id, _ := l.Inner.Charge(...) // discarded error
return id, nil
}
Always return the inner's error unchanged. A decorator that masks errors is worse than no decorator.
15. Debugging a decorator chain¶
When the chain behaves wrong, walk through it.
15.1 Log the chain at startup¶
Hand-rolled documentation, but it's the simplest tool when you're trying to remember which order is in effect.
15.2 Add unique log strings per layer¶
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println(">>> logging enter")
next.ServeHTTP(w, r)
log.Println("<<< logging exit")
})
}
A request log now shows the exact order of layers wrapping a given handler. The chain becomes visible.
15.3 Reflect on the type chain¶
func typeChain(c Charger) []string {
var chain []string
for {
chain = append(chain, fmt.Sprintf("%T", c))
u, ok := c.(interface{ Unwrap() Charger })
if !ok { return chain }
c = u.Unwrap()
}
}
If every decorator implements Unwrap(), you can introspect the chain at runtime. Useful for /debug endpoints.
16. Tricky points¶
16.1 The decorator that holds the consumer's pointer¶
type CountingCharger struct {
Inner Charger
count *int // pointer to caller's counter
}
func (c *CountingCharger) Charge(...) error {
*c.count++
return c.Inner.Charge(...)
}
The caller can read count outside the decorator. But two CountingCharger instances sharing the same counter is unusual — document loudly.
16.2 Wrapping types that aren't interfaces¶
// Can you decorate a concrete type without an interface?
type Logger struct{ /* ... */ }
func (l *Logger) Log(s string) { /* ... */ }
type CountingLogger struct {
Inner *Logger
n int
}
func (c *CountingLogger) Log(s string) {
c.n++
c.Inner.Log(s)
}
You can, but the consumer now depends on the concrete *CountingLogger, not on a shared interface. Refactoring to a different logger means changing every call site. Always wrap an interface, even if it's a one-method interface defined for the purpose.
16.3 The "stack" abstraction¶
Some libraries (especially in Java land) talk about a "middleware stack". In Go, the chain is a sequence of function applications, not a runtime stack. There's no push/pop outside what the call stack does naturally. Don't introduce stack semantics where they aren't needed.
16.4 Generic helper traps¶
func Compose[T any](fns ...func(T) T) func(T) T {
return func(t T) T {
for _, fn := range fns { t = fn(t) }
return t
}
}
Looks like a decorator chain composer. It is — but only for "transformer" decorators that return a new T. It doesn't work for "wrap-and-delegate" decorators that return the same T after wrapping. The signature func(T) T is ambiguous; it could mean either.
17. Test¶
Q1. What's the issue?
type Recorder struct {
Inner Charger
history []int
}
func (r Recorder) Charge(ctx context.Context, amount int) error {
r.history = append(r.history, amount)
return r.Inner.Charge(ctx, amount)
}
Answer
Value receiver. `append` may return a new slice header (especially if capacity is full), but `r` is a copy, so the assignment doesn't reach the caller's instance. After the call, `r.history` (in the caller) might still be empty. Fix: pointer receiver `(r *Recorder)`. Now the assignment persists.Q2. Which order runs first?
Answer
`A` runs first (outermost), then `B`, then `C`, then `handlerFunc`. The slice order matches reading order: top runs first. The implementation iterates the slice in reverse so the *last* middleware wraps the handler first; the *first* middleware wraps last (ending up outermost).Q3. Fix this chain bug:
Answer
The current order is wrong. `Auth` is the outermost — it runs first, then `RateLimit`. Unauthorized requests get rate-limited *after* auth has already done CPU work. Fix: `RateLimit(Auth(handler))`. Now `RateLimit` is outermost; it rejects excess requests before `Auth` runs at all.18. Cheat sheet¶
| Situation | Approach |
|---|---|
| Multi-method interface with state to track | Struct decorator |
| HTTP / RPC middleware | Function decorator func(Handler) Handler |
| Interface with 5+ methods, decorate one or two | Embedding-based decorator |
| Multiple concerns over one type | Compose with Chain(h, A, B, C) |
| Per-request state across before/after | Closure variable inside the wrapper function |
| Per-instance state (counters, breakers) | Struct decorator with mutex / atomic |
| Conditional decoration | If(cond, mw) helper that returns identity if false |
| Observability concerns | Outside resilience concerns |
| Rate limiting | Outside auth (usually) |
| Recovery | Inside tracing |
| Need to inspect chain | Unwrap() Inner method on each decorator |
19. Summary¶
Decorator in Go is the workhorse of cross-cutting concerns. Mastery is:
- Picking struct vs function form by the presence of state.
- Composing chains with order awareness (observability outside resilience, rate-limit before auth).
- Handling panics in middleware safely (defer + recover, but only this goroutine).
- Avoiding leaky abstractions (no concrete-type assertions, no per-decorator state mutation through the inner).
- Testing each decorator in isolation with a fake inner, then testing chain order separately.
The next step is senior.md — distributed middleware, request-scoped vs server-scoped decorators, decorator design in published libraries, contracts and Liskov for decorator chains, and case studies (chi, gorilla, gRPC interceptors, otelhttp).