Skip to content
Software Atlas

Structural Patterns

Adapter, Decorator, Proxy, Facade and Composite: the handful of ways to wrap or combine objects so they fit together — from Go's io.Reader and HTTP middleware to JavaScript's Proxy and React's component tree.

⏱ ~45 min read14 sections10 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceStructural patterns are a handful of standard ways to wrap one object in another, or combine many objects into one, so that pieces of code that weren't built for each other fit together.

A Design pattern is a named solution to a problem that keeps coming up. Nobody ships it as a library; it's a shared vocabulary. When a teammate says "put a caching proxy in front of it", everyone pictures the same code. This page covers the five structural patterns you'll meet every week, plus two you'll meet occasionally.

The five shapes
Adapter
different outside
youSDK
▢ outside, ○ inside
Decorator
same outside, adds
real+log+gzip
wrap, wrap again
Proxy
same outside, gatekeeps
youproxyreal?
maybe calls through
Facade
one simple front
youone()
hides many parts
Composite
one = many
dirdirfilefilefilefile
folders in folders
Adapter, decorator and proxy all hold one inner object. Facade holds several different ones. Composite holds many of its own kind.

The one trick behind almost all of them

Look at the picture again: four of the five are the same shape in code. A Wrapper holds another object inside it and forwards calls to it. What makes them different patterns is why you wrap, and what the wrapper's Interface (the set of methods callers can use on it) looks like from outside:

PatternThe wrapper's outside looks like…Why you wrap
Adaptera different interface from the insideto make something fit an interface it wasn't built for
Decoratorthe same interface as the insideto add behavior (logging, compression, counting)
Proxythe same interface as the insideto control access (cache, check, delay, send over the network)
Facadea smaller, simpler interface over many insidesto hide a complicated subsystem
Compositethe same interface as each childto treat a group exactly like a single item

That table is the whole topic in miniature. The rest of the page makes each row concrete, in Go and JavaScript, with the real places you already use them: Go's io.Reader, HTTP Middleware, JavaScript's built-in Proxy, and React.

Foundations§02

Primer: interfaces and wrappers

Every pattern on this page is built from two ideas: an interface and a wrapper. If you're comfortable with both, skip to §03.

Idea 1: an interface is a promise about methods

An Interface lists method names and nothing else. Any type that has those methods can be used wherever the interface is expected. The code that uses the interface doesn't know or care which real type it got.

// The interface: "anything that can Notify".
type Notifier interface {
    Notify(msg string) error
}

// One real type that has that method.
type EmailNotifier struct {
    Addr string
}

func (e EmailNotifier) Notify(msg string) error { // ← this method makes it a Notifier
    fmt.Println("email to", e.Addr+":", msg)
    return nil
}

// Code that only knows the interface.
func alertOps(n Notifier) {
    n.Notify("disk almost full")
}

alertOps(EmailNotifier{Addr: "ops@example.com"})

In JavaScript the same idea is informal and called duck typing: "if it walks like a duck and quacks like a duck, it's a duck". If the object has a notify method, alertOps is happy.

Idea 2: a wrapper holds another object and forwards to it

A Wrapper is a type that holds another value inside it and passes calls through. On the way through it can change the call, add to it, or refuse it.

A wrapper forwards the call
Follow one call from alertOps, through the wrapper, to the real notifier and back.
alertOps(n)PrefixNotifier (the wrapper)EmailNotifier(the inner object)
Press ▶ to follow the message.
// A wrapper that is ALSO a Notifier, and holds another Notifier inside.
type PrefixNotifier struct {
    Inner  Notifier // ← the wrapped value
    Prefix string
}

func (p PrefixNotifier) Notify(msg string) error {
    return p.Inner.Notify(p.Prefix + msg) // ← forward, slightly changed
}

alertOps(PrefixNotifier{
    Inner:  EmailNotifier{Addr: "ops@example.com"},
    Prefix: "[prod] ",
})
// email to ops@example.com: [prod] disk almost full

Notice that alertOps didn't change at all. It still just calls Notify. That's the superpower: because the wrapper has the same methods as the thing inside, you can slip it in without touching the code on either side. Holding a value as a field like this is called Composition ("has-a"), and it's how Go and modern JavaScript build almost everything.

Practitioner§03

Adapter: make it fit

In one sentenceAn adapter wraps something whose methods don't match what your code expects, and translates: your interface on the outside, their interface on the inside.

The problem: two providers, two shapes

Your shop takes payments. The checkout code wants one simple thing: "charge this customer this many cents, give me a receipt ID". But you use two payment providers, and each ships its own SDK with its own idea of what that call looks like:

what the two SDKs offer (you can't change these)
// Provider A: amount in cents, returns an object with a status string.
func (c *stripeish.Client) CreatePaymentIntent(p stripeish.PaymentIntentParams) (*stripeish.PaymentIntent, error)

// Provider B: amount as a decimal string like "12.50", returns (txn, ok, err).
func (c *payfast.Client) Pay(customer string, amount string) (txn string, ok bool, err error)

If checkout calls those directly, it has to know both shapes, both unit conventions and both error styles. Add a third provider and checkout changes again. Instead, checkout defines the shape it wants, and you write one small adapter per provider:

payments.go
// The interface checkout depends on. Defined by US, in our code.
type PaymentGateway interface {
    Charge(ctx context.Context, customerID string, cents int64) (receiptID string, err error)
}

// Adapter for provider A.
type StripeAdapter struct {
    client *stripeish.Client // ← the wrapped SDK client
}

func (a StripeAdapter) Charge(ctx context.Context, customerID string, cents int64) (string, error) {
    pi, err := a.client.CreatePaymentIntent(stripeish.PaymentIntentParams{
        Amount: cents, Currency: "usd", Customer: customerID, // ← translate arguments
    })
    if err != nil {
        return "", fmt.Errorf("stripe: %w", err)
    }
    if pi.Status != "succeeded" {                            // ← translate their "status" into our error
        return "", fmt.Errorf("stripe: payment %s", pi.Status)
    }
    return pi.ID, nil
}

// Adapter for provider B.
var ErrDeclined = errors.New("payment declined")

type PayFastAdapter struct {
    client *payfast.Client
}

func (a PayFastAdapter) Charge(ctx context.Context, customerID string, cents int64) (string, error) {
    amount := fmt.Sprintf("%d.%02d", cents/100, cents%100) // ← translate units: 1250 → "12.50"
    txn, ok, err := a.client.Pay(customerID, amount)
    if err != nil {
        return "", fmt.Errorf("payfast: %w", err)
    }
    if !ok {
        return "", ErrDeclined                              // ← our own error value
    }
    return txn, nil
}

Try it. Pick a provider and see whether it fits the socket checkout expects:

Adapter plug
Checkout has a socket shaped like PaymentGateway.Charge. Each provider's SDK has its own plug. Try plugging one in directly, then with an adapter.
checkout expectsCharge(id, cents)StripeAdaptertranslatesstripeishSDK — can't change✕ shapes don't match
SDK method: CreatePaymentIntent(PaymentIntentParams) (*PaymentIntent, error)
$ go build
cannot use client (variable of type *stripeish.Client) as PaymentGateway value in argument to NewCheckout: *stripeish.Client does not implement PaymentGateway (missing method Charge)
The SDK has no Charge method, so the compiler refuses. You can't edit the SDK, so add an adapter.

What an adapter actually translates

An adapter is usually boring code, and that's the point: all the ugliness of someone else's API lives in one small file. Look at what the two adapters above translate:

1

Names and shapes

Charge(id, cents) becomes CreatePaymentIntent(params{…}) or Pay(customer, amount).
2

Units and types

Cents as an integer vs a decimal string. Getting this wrong charges someone 100× too much, so it belongs in one tested place.
3

Errors and results

A status string, an ok flag, an error — all turned into your error values that checkout already knows how to handle.

Two big wins follow. Switching providers means writing a new adapter; checkout doesn't change. And testing checkout gets easy: pass in a fake PaymentGateway (a Test double) that records the call instead of charging a real card. You can't do that if checkout calls the SDK directly. (Both ideas get a full treatment in Dependency Injection.)

Go's sneakiest adapter: http.HandlerFunc

Go's web server calls an HTTP handler: any value with a ServeHTTP method.

type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

But most of the time you just want to write a function, not a struct with a method. Go's standard library solves this with a three-line adapter. This is the real code from net/http:

net/http (standard library)
type HandlerFunc func(http.ResponseWriter, *http.Request) // ← a TYPE whose values are functions

func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r) // ← "to serve, just call myself"
}
func hello(w http.ResponseWriter, r *http.Request) {
    fmt.Fprintln(w, "hello")
}

var h http.Handler = http.HandlerFunc(hello) // ← adapter: function → Handler
http.Handle("/hello", h)
// (http.HandleFunc("/hello", hello) does this conversion for you.)

This adapter is tiny but you'll see it on almost every line of §05: every Go middleware returns http.HandlerFunc(func(w, r) { … }).

JavaScript's classic adapter: callback → promise

Older Node APIs (and many old SDKs) report results through a Callback instead of returning a Promise. You can't await a callback. An adapter fixes that once, so the rest of your code stays modern:

// Old SDK (can't change it): legacyGetUser(id, (err, user) => { … })

function getUser(id) {                    // ← adapter: same job, promise-shaped
  return new Promise((resolve, reject) => {
    legacyGetUser(id, (err, user) => {
      if (err) reject(err);               // callback's error  → rejected promise
      else resolve(user);                 // callback's result → fulfilled promise
    });
  });
}

// inside an async function (or at the top level of an ES module):
const user = await getUser(42);           // now it works with await and try/catch

Node ships this exact adapter as util.promisify: const getUser = promisify(legacyGetUser). It works with any function that follows Node's "error first, then result" callback convention. (Why promises beat callbacks: Async / Await §05.)

Practitioner§04

Decorator: add behavior, keep the shape

In one sentenceA decorator wraps an object and has the same interface as the object it wraps, so it can add behavior on the way in or out — and you can stack as many as you like.

The phone-case picture from §01 hides one important detail: because a decorator looks exactly like the thing it wraps, you can wrap a decorator in another decorator. A phone in a battery case in a waterproof pouch is still "a phone" to whoever holds it.

Go's favorite decorator: io.Reader

Go's io.Reader interface has a single method:

type Reader interface {
    Read(p []byte) (n int, err error) // fill p with up to len(p) bytes; say how many
}

Files, network connections and HTTP request bodies are all readers. So are dozens of wrappers in the standard library that take a reader and give you back a reader that does something extra. Here's one you can write yourself in a few lines, a reader that counts bytes:

counting.go
type CountingReader struct {
    r io.Reader // ← the wrapped reader
    N int64     // total bytes read so far
}

func (c *CountingReader) Read(p []byte) (int, error) {
    n, err := c.r.Read(p) // ← forward the call unchanged
    c.N += int64(n)       // ← the added behavior
    return n, err
}

*CountingReader has a Read method with the right signature, so it is an io.Reader, and you can put it anywhere a reader goes. The standard library is full of the same shape:

WrapperTakesGives backAdds
bufio.NewReader(r)a readera readera Buffer / buffered reader: reads 4 KB at a time from r, serves your small reads from memory
gzip.NewReader(r)a reader of compressed bytesa reader of plain bytesgzip decompression
io.TeeReader(r, w)a reader + a writera readercopies every byte it reads into w (like a T-junction in a pipe)
io.LimitReader(r, n)a readera readerstops after n bytes (protects you from huge uploads)

Now stack them. Read a compressed log file, count the compressed bytes off disk, and compute a Hash function of the decompressed contents, all in one pass:

stack.go
f, err := os.Open("access.log.gz")
if err != nil {
    return err
}
defer f.Close()

counted := &CountingReader{r: f}     // layer 1: counts bytes coming off the disk
gz, err := gzip.NewReader(counted)   // layer 2: decompresses
if err != nil {
    return err
}
h := sha256.New()
tee := io.TeeReader(gz, h)           // layer 3: feeds every plain byte into the hash

n, err := io.Copy(os.Stdout, tee)    // pull everything through all three layers
// counted.N = compressed size; n = decompressed size; h.Sum(nil) = hash of the contents

The call goes in through the outermost layer and the data comes back out through every layer. And the order matters: put the counter above gzip and it counts decompressed bytes instead. Build your own stack and watch:

Decorator stack builder
Toggle layers and reorder them (top = outermost, nearest the caller). Then send a read down the stack.
👤 caller — wants plain text, 512 B per Read
io.TeeReader → sha256hashes plain text
gzip.Readercompressed → plain
CountingReadercounts compressed
bufio.Reader
💾 access.log.gz — 100 KB compressed (400 KB of text inside)
// error handling omitted to keep the stack readable
f, _ := os.Open("access.log.gz")  // 100 KB on disk
var r io.Reader = f
cnt := &CountingReader{r: r}
r = cnt
r, _ = gzip.NewReader(r)
r = io.TeeReader(r, hash)
// caller: reads 512 bytes at a time from r
Results of reading the whole file
Caller receives: 400 KB of plain text
cnt.N: 100 KB (compressed bytes pass that point)
sha256 of: the text inside
Disk reads: ≈ 25 (4 KB each)
Try moving CountingReader above gzip.Reader: same code, but now it counts 400 KB of text instead of 100 KB off the disk. Order changes meaning.

Decorators in JavaScript: wrap the function

JavaScript functions are values (first-class), so the lightest decorator is just a function that takes a function and returns a new function with the same signature:

decorators.js
function withTiming(name, fn) {
  return async (...args) => {                     // ← same arguments in…
    const start = performance.now();
    try {
      return await fn(...args);                   // ← …forwarded to the real function
    } finally {
      console.log(`${name} took ${(performance.now() - start).toFixed(1)} ms`);
    }
  };
}

function withRetry(fn, attempts = 3) {
  return async (...args) => {
    for (let i = 1; ; i++) {
      try {
        return await fn(...args);
      } catch (err) {
        if (i >= attempts) throw err;             // out of attempts: give up
      }
    }
  };
}

// Stack them, just like the readers:
const getUser = withTiming("getUser", withRetry(fetchUser));
await getUser(42); // times the whole thing, including retries

Swap the order to withRetry(withTiming("getUser", fetchUser)) and you'd log one timing per attempt instead of one in total. Same lesson as the readers: the outer wrapper sees everything the inner ones do.

Practitioner§05

HTTP middleware: decorators for handlers

In one sentenceHTTP middleware is a decorator for request handlers: it wraps a handler, runs code before and/or after it, and gives back something that is still a handler.

Every web app needs the same chores around every request: log it, time it, check who's calling, recover from crashes. You don't want those lines copy-pasted into 80 handlers. Middleware lets each chore live in one place and wrap all the handlers at once.

The Go shape: func(http.Handler) http.Handler

A Go middleware is a function that takes a handler and returns a new handler. The new handler does its chore and calls the old one (traditionally named next) somewhere in the middle:

middleware.go
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)                      // ← call the wrapped handler
        log.Printf("%s %s took %v",               // ── after ──
            r.Method, r.URL.Path, time.Since(start))
    })
}

func RequireAuth(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if r.Header.Get("Authorization") == "" {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return                                // ← never calls next: short-circuit
        }
        next.ServeHTTP(w, r)
    })
}

// Wrap: Logging is outside, RequireAuth is inside, the real handler is in the middle.
var app http.Handler = Logging(RequireAuth(http.HandlerFunc(listOrders)))

Logging(RequireAuth(handler)) reads inside-out, which gets awkward with five layers. Most codebases add a tiny helper so the list reads top to bottom, outermost first:

type Middleware func(http.Handler) http.Handler

// Chain(h, A, B, C) == A(B(C(h))): A is outermost, so it runs first.
func Chain(h http.Handler, mws ...Middleware) http.Handler {
    for i := len(mws) - 1; i >= 0; i-- { // wrap from the last one inward…
        h = mws[i](h)                    // …so the first one ends up outside
    }
    return h
}

app := Chain(http.HandlerFunc(listOrders), Logging, RequireAuth)

The onion

Picture the layers as the rings of an onion. A request travels inward through every layer's "before" code to the handler, then the response travels outward through every layer's "after" code, in reverse order.

Middleware is an onion
LoggingAuthhandler① starttimer② checktoken③ nothingto do④ log 200,12 msrequest →→ response
Cut the onion in half: the request goes in from the left through Logging's “before” code, then Auth's “before” code, to the handler. The response comes back out through the same layers in reverse order: Auth's “after”, then Logging's “after”. If Auth rejects the request, the path turns around at Auth and the handler never runs.

Step through a real request. Flip the token switch to see what short-circuiting looks like:

A request through three middlewares
Watch the stack grow on the way in and shrink on the way out. Times are illustrative.
Code
1func RequestID(next http.Handler) http.Handler {
2 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3 ctx := withRequestID(r.Context(), newID())
4 next.ServeHTTP(w, r.WithContext(ctx))
5 })
6}
7
8func Logging(next http.Handler) http.Handler {
9 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
10 start := time.Now()
11 rec := &statusRecorder{ResponseWriter: w, status: 200}
12 next.ServeHTTP(rec, r)
13 log.Printf("%s %d %v", requestID(r.Context()), rec.status, time.Since(start))
14 })
15}
16
17func Auth(next http.Handler) http.Handler {
18 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
19 user, ok := checkToken(r.Header.Get("Authorization"))
20 if !ok {
21 http.Error(w, "unauthorized", http.StatusUnauthorized)
22 return
23 }
24 next.ServeHTTP(w, r.WithContext(withUser(r.Context(), user)))
25 })
26}
27
28app := Chain(http.HandlerFunc(listOrders), RequestID, Logging, Auth)
running now (outer → inner)
nothing yet
context this layer's r sees
empty
timeline
t=0.00 ms GET /orders arrives
Chain built the onion at startup: RequestID outside, then Logging, then Auth, then the handler. Now a request for GET /orders arrives. Go's server calls the outermost handler: RequestID.
1 / 8

Three facts fall out of the onion, and every one of them causes real bugs when forgotten:

1

Outermost runs first and last

That's why logging and timing go outside auth: so even rejected requests get logged with their 401 and their duration.
2

Short-circuits skip the inside

A 401 from auth means the handler (and any middleware inside auth) never runs. Put the expensive work inside the cheap checks.
3

Inner layers can add data for later ones

Auth figured out who the user is; the handler needs that. Pass it along on the request (next heading).

Passing data inward: the request's context

Auth middleware usually does more than say yes or no: it figures out who the user is, and the handler wants to know. In Go you attach it to the request's context.Context and pass a copy of the request inward:

type userKey struct{} // a private key type, so no other package can collide with it

func Authenticate(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        user, err := lookupUser(r.Header.Get("Authorization"))
        if err != nil {
            http.Error(w, "unauthorized", http.StatusUnauthorized)
            return
        }
        ctx := context.WithValue(r.Context(), userKey{}, user) // ← new context with the user in it
        next.ServeHTTP(w, r.WithContext(ctx))                  // ← pass the NEW request inward
    })
}

func listOrders(w http.ResponseWriter, r *http.Request) {
    user, ok := r.Context().Value(userKey{}).(User) // ← read it back
    if !ok {                                        // missing? (e.g. auth middleware not applied)
        http.Error(w, "internal error", http.StatusInternalServerError)
        return
    }
    // … use user …
}

The same thing in JavaScript: Express and Koa

JavaScript web frameworks use the same pattern; only the shape of the function differs.

import Koa from "koa";
const app = new Koa();

app.use(async (ctx, next) => {          // outermost: timing
  const start = performance.now();
  await next();                          // ← everything inside runs here
  console.log(`${ctx.method} ${ctx.path} → ${ctx.status} in ${(performance.now() - start).toFixed(1)} ms`);
});

app.use(async (ctx, next) => {          // auth
  if (!ctx.get("Authorization")) {
    ctx.status = 401;                    // short-circuit: answer and
    ctx.body = "unauthorized";
    return;                              // don't call next()
  }
  await next();
});

app.use(async (ctx) => { ctx.body = await listOrders(); }); // the handler
app.listen(3000);

In Koa, await next() returns when every inner layer has finished, so "after" code goes right below it, exactly like Go's next.ServeHTTP. In Express, next() returns before async handlers finish, so "after" code has to listen for the response's finish event instead. Same onion, different plumbing.

Practitioner§06

Proxy: same interface, controlled access

In one sentenceA proxy stands in for another object with exactly the same interface, and decides whether, when and how the real object gets called.

Back to the receptionist from §01. You ask them for the person you came to see, and one of several things happens: they answer from memory because you asked yesterday (caching), they check your badge first (protection), they say "come back in a minute" (rate limiting), or the person works in another building and the receptionist phones them for you (remote). In every case you talked to the receptionist exactly as you'd talk to the real person.

The caching proxy

The most common proxy in backend code. Fetching a user from the database takes about 20 ms; fetching it from memory takes well under a microsecond. A Cache proxy sits in front of the store and only calls it on a miss:

caching.go
type UserStore interface {
    GetUser(ctx context.Context, id string) (User, error)
}

type CachingUserStore struct {
    next  UserStore          // ← the real store (a database, another service…)
    mu    sync.Mutex         // protects cache: many requests run at once
    cache map[string]User
}

func NewCachingUserStore(next UserStore) *CachingUserStore {
    return &CachingUserStore{next: next, cache: make(map[string]User)}
}

func (c *CachingUserStore) GetUser(ctx context.Context, id string) (User, error) {
    c.mu.Lock()
    u, ok := c.cache[id]
    c.mu.Unlock()
    if ok {
        return u, nil                        // ← hit: the real store is never called
    }
    u, err := c.next.GetUser(ctx, id)        // ← miss: ask the real store
    if err != nil {
        return User{}, err                   // don't cache failures
    }
    c.mu.Lock()
    c.cache[id] = u
    c.mu.Unlock()
    return u, nil
}

Click a few users. Watch which calls reach the database:

Caching proxy
The caller only sees a UserStore. It can't tell whether the proxy answered from memory or asked the database.
caller
time spent waiting: 0.000 ms
CachingUserStore (proxy)
hits 0 · misses 0
cache empty
databasecalls: 0
user 1: Ada
user 2: Bob
user 3: Cleo
Click GetUser(1) twice. Then rename user 2, fetch it, and see what a cache without expiry does.

A real cache also needs an expiry time (so changed users eventually show up), a size limit (so memory doesn't grow forever), and protection against 1,000 simultaneous misses for the same key all hitting the database (Go's golang.org/x/sync/singleflight does that). The pattern stays the same; only the inside of GetUser grows.

Three more kinds of proxy

⏳

Lazy (virtual) proxy

Looks ready immediately, but creates the expensive real object only on the first call: Lazy vs eager initialization. Startup stays fast, and code paths that never use it never pay for it.
🌐

Remote proxy

An object in your process whose methods actually run on another machine. Every gRPC client is one: client.GetUser(ctx, req) looks local but sends a network request. This is called RPC, and the generated client is the "stub".
🛡️

Protection / limiting proxy

Checks permissions, or enforces a Rate limit, before forwarding. Same interface, so the caller can't accidentally skip the check.

The lazy proxy in modern Go is nearly a one-liner, thanks to sync.OnceValues (Go 1.21+):

// Nothing is created yet. The function runs once, on the first call to getDB().
var getDB = sync.OnceValues(func() (*sql.DB, error) {
    // needs a driver import, e.g. _ "github.com/jackc/pgx/v5/stdlib"
    db, err := sql.Open("pgx", os.Getenv("DATABASE_URL")) // cheap: doesn't connect yet
    if err != nil {
        return nil, err
    }
    return db, db.Ping() // ← the slow part: actually connects to the database
})

db, err := getDB() // first call connects (slow); every later call: same db, same err, instantly

JavaScript's built-in Proxy

JavaScript has the pattern built into the language. new Proxy(target, handler) returns an object that looks just like target, but every property read, write, delete and so on goes through the functions in handler first. Those functions are called traps.

validated.js
const user = new Proxy({ name: "Ada", age: 36 }, {
  set(target, prop, value) {                 // trap: runs on EVERY assignment
    if (prop === "age" && (!Number.isInteger(value) || value < 0)) {
      throw new TypeError(`age must be a non-negative integer, got ${value}`);
    }
    target[prop] = value;                    // allowed: write to the real object
    return true;                             // ← "the write succeeded"
  },
  get(target, prop, receiver) {              // trap: runs on EVERY read
    if (!(prop in target)) console.warn(`no property "${String(prop)}"`);
    return Reflect.get(target, prop, receiver); // do the normal read
  },
});

user.age = 37;      // fine
user.age = "old";   // TypeError: age must be a non-negative integer, got old
user.nmae;          // warns: no property "nmae"  (typo caught!)

This is how Vue 3's reactivity works: it wraps your data in a Proxy whose set trap re-renders whatever used that property. Immer uses a Proxy to record the changes you make to a draft object. Neither library asks you to call special methods; you just use normal objects.

Proxy or decorator? Read the intent

Here's the uncomfortable truth: CachingUserStore and §04's CountingReader have identical structure. Same interface, holds the real thing, forwards calls. The difference is purely what they're for. Watch the same call go through each:

Same call, different fate
Both wrappers have the same GetUser method and hold the same real store. Call it a few times, then deny access.
decoratorreal store called: 0 / 0
caller
TimingStore (decorator)
real store
proxyreal store called: 0 / 0
caller
CachingStore (proxy)
real store
Press ▶ three times. The decorator always reaches the real store; the proxy decides.
DecoratorProxy
Main question it answers"What extra should happen?""Should the real call happen at all, and how?"
Does the real object always get called?Yes (it adds around the call)Not necessarily: cache hit, access denied, rate limited, not created yet
Who usually builds the stack?The caller, on purpose, often several layersOften hidden: a factory or library hands you the proxy and you don't know
Exampleslogging, timing, compression, metricscache, lazy init, gRPC stub, permission check, rate limiter

Don't lose sleep over the label. The reason to know both names is so you can say what a wrapper is for in a code review, and spot when a "logging decorator" has quietly started skipping calls.

Practitioner§07

Facade: one simple front

In one sentenceA facade gives you one simple method for a job that really needs several different objects working in the right order, and hides those objects behind it.

Sending an invoice is five jobs

"Email the customer their invoice" sounds like one step. Underneath it's five: load the order, render a PDF, store the PDF somewhere permanent, email a link to the customer, and record that it was sent. Each step uses a different library with its own setup and its own errors. Without a facade, every caller (the checkout flow, the admin panel, the monthly batch job) repeats all five steps, and they drift apart.

One method in front, five steps behind
checkoutadmin panelmonthly batchInvoiceServiceSendInvoice(id)OrderRepo1 Get · 5 MarkInvoicedPDFRenderer2 RenderBlobStore3 PutMailer4 Send
Every caller makes one call. Only InvoiceService knows the four collaborators and the order of the five steps (numbered).
invoices.go
// The facade: holds the subsystem parts, exposes one method.
type InvoiceService struct {
    orders  OrderRepo
    pdf     PDFRenderer
    storage BlobStore
    mail    Mailer
}

func (s *InvoiceService) SendInvoice(ctx context.Context, orderID string) error {
    order, err := s.orders.Get(ctx, orderID)
    if err != nil {
        return fmt.Errorf("load order: %w", err)
    }
    doc, err := s.pdf.Render("invoice.tmpl", order)          // []byte of PDF
    if err != nil {
        return fmt.Errorf("render pdf: %w", err)
    }
    url, err := s.storage.Put(ctx, "invoices/"+orderID+".pdf", doc)
    if err != nil {
        return fmt.Errorf("store pdf: %w", err)
    }
    if err := s.mail.Send(ctx, order.Email, "Your invoice", url); err != nil {
        return fmt.Errorf("email: %w", err)
    }
    return s.orders.MarkInvoiced(ctx, orderID)
}

// Every caller now writes ONE line:
//   err := invoices.SendInvoice(ctx, "ord_123")

What the facade bought you:

  • One place for the order of steps. Store before email (so the link works), mark invoiced last (so a failure means "try again" rather than "silently skipped").
  • One place for error wording, retries and logging for this whole job.
  • Callers depend on one method, not on four libraries. Swap the PDF library and only InvoiceService changes: lower Coupling.

A facade doesn't lock the subsystem away. Code that genuinely needs the PDF renderer can still use it directly. The facade is the easy path for the common case, not a wall.

Adapter vs facade

They both "put a nicer interface in front", so people mix them up. The test is how many things are behind it, and whose interface it matches:

AdapterFacade
Wrapsone objectmany objects
Its outside interfaceone that already exists (your PaymentGateway)a new, smaller one invented for the job
Goalmake it fitmake it simple

When facades go wrong: the god facade

Facades are where "just add one more method" goes to live. InvoiceService gets SendReminder, then RefundOrder, then ExportForAccounting, then UpdateCustomerAddress… Two years later it has 60 methods, 14 dependencies, a 3,000-line file, and every team edits it. That's a God object.

The warning signs are easy to measure: the Constructor takes more than 5–6 dependencies; methods that don't share any of them (low Cohesion); the name ends in Manager, Helper or Service and nobody can say what it's not for. The fix is to split by job: InvoiceSender, Refunds, AccountingExport, each a small facade of its own. (Anti-patterns covers god objects in depth.)

Practitioner§08

Composite: groups that act like one

In one sentenceA composite lets a group of things answer the same questions as a single thing, by asking each member and combining the answers. Groups can contain groups, so you get a tree.

Folders inside folders

A folder can contain files and other folders. Ask a file "how big are you?" and it knows. Ask a folder, and it asks each of its children and adds up the answers. Some of those children are folders, which ask their children, and so on down. That shape is a Tree, and the "ask yourself on the smaller pieces" trick is Recursion.

The key move: files and folders share one interface, so a folder doesn't need to know which kind each child is.

fs.go
type Node interface {
    Name() string
    Size() int64
}

// Leaf: has no children.
type File struct {
    name  string
    bytes int64
}

func (f File) Name() string { return f.name }
func (f File) Size() int64  { return f.bytes }   // ← leaf: just knows

// Composite: holds other Nodes — files OR directories.
type Dir struct {
    name     string
    children []Node
}

func (d *Dir) Name() string { return d.name }
func (d *Dir) Size() int64 {
    var total int64
    for _, child := range d.children {
        total += child.Size()                   // ← same question, asked of each child
    }
    return total
}

root := &Dir{name: "project", children: []Node{
    File{"go.mod", 120},
    &Dir{name: "src", children: []Node{File{"main.go", 2048}, File{"util.go", 900}}},
}}
fmt.Println(root.Size()) // 3068

The recursion stops by itself at the leaves: a file's Size() doesn't call anything. Expand the tree, then call Size() on any folder and watch the calls travel down and the totals come back up:

Composite tree explorer
Click ▸ to expand folders. Click Size() on any folder (or file) to watch the recursion.
📁 project/
📄 go.mod120 B
📄 README.md2.4 KB
📁 cmd/
📁 internal/
📁 users/
📁 billing/
📁 assets/
call stackdepth 0
idle
Size() calls so far: 0
Deepest stack: 0
Try Size() on project/, then collapse internal/ and run it again: the call count doesn't change.

Notice that Size() visits every node below the one you asked, whether or not it's expanded in the picture. A folder with 10,000 files below it means 10,000 calls every time. Fine for a folder on screen; a problem if it runs on every request (§12 shows the fix).

You already use composites every day

⚛️

React components

A <Page> contains a <Header> and a <List>, which contains <Item>s. Every one is a component with the same contract (props in, UI out), and React renders the tree by rendering each node's children. A button and a whole page are used exactly the same way.
🌳

The DOM

Every HTML element is a node that can hold other nodes. element.textContent on a <div> gathers the text of everything inside it — a composite operation, just like Size().
🧭

Nested HTTP routers

In Go, a router (http.ServeMux) is itself an http.Handler, so a router can hold another router as one of its routes (below).
routers.go
api := http.NewServeMux()                          // a whole sub-app…
api.HandleFunc("GET /users/{id}", getUser)
api.HandleFunc("GET /orders", listOrders)

root := http.NewServeMux()
root.Handle("/api/", http.StripPrefix("/api", api)) // …mounted as ONE route of the parent
root.HandleFunc("GET /healthz", health)

http.ListenAndServe(":8080", root)
// GET /api/users/7 → root sees "/api/…" → strips "/api" → api sees "/users/7" → getUser

Because the sub-router and a single handler share the http.Handler interface, the parent can't tell them apart, and doesn't need to. That's the composite promise: treat one and many the same way.

A design choice: where does Add go?

Only folders can have children, so where does an Add(child) method belong? Putting it on the shared Node interface lets callers treat everything identically, but then File.Add has to fail at runtime. Putting it only on Dir means callers must know they hold a Dir to add to it. In Go, keep Add on Dir only: interfaces should stay small, and the compiler then stops you from adding a child to a file.

Practitioner§09

The other two: bridge and flyweight

The classic catalog has two more structural patterns. You'll meet them less often by name, but you'll write the first one constantly without noticing.

Bridge: two things that change independently

You send notifications. There are kinds of notification (an urgent alert, a daily digest) and channels to send them on (email, SMS, Slack). If each combination gets its own type, UrgentEmail, UrgentSMS, DigestSlack… then 2 kinds × 3 channels = 6 types, and adding one channel means writing two more. With 5 kinds and 6 channels you're writing 30.

A bridge splits the two dimensions: a notification holds a channel through an interface. Now each side grows on its own: 5 kinds + 6 channels = 11 small types, not 30.

Glued together vs bridged
without a bridge2 × 3 = 6 types
UrgentEmailUrgentSMSUrgentSlackDigestEmailDigestSMSDigestSlack
with a bridge2 + 3 = 5 types
UrgentAlertDigestAlert
Via
Channel
────▶
EmailSMSSlack
Left: one type per combination; adding a channel means adding a type for every kind. Right: kinds hold a Channel field; adding a channel is one new type.
type Channel interface {             // dimension 2: HOW it's delivered
    Deliver(to, text string) error
}

type UrgentAlert struct {            // dimension 1: WHAT is sent
    Via Channel                      // ← the "bridge" to the other dimension
}

func (a UrgentAlert) Send(to, problem string) error {
    return a.Via.Deliver(to, "🚨 ACTION NEEDED: "+problem)
}

alert := UrgentAlert{Via: SMS{}}     // any kind × any channel, picked at runtime

If that looks like ordinary "hold an interface as a field", that's because it is. The bridge pattern got a name in the 1990s, when the common habit was to build a big Inheritance tree of classes. In Go (which has no inheritance) and modern JavaScript, Composition is the default, so you get bridges for free. The useful part to remember is the warning sign: type names that are a grid of two words (UrgentEmail, DigestEmail, UrgentSms…) mean two dimensions are glued together.

Flyweight: share what never changes

A text editor shows a million characters. If each character carried its own copy of the font, size and color, that's a million copies of the same few styles. A flyweight keeps one shared copy of each distinct style and gives every character a reference to it. This is only safe because the shared data is Immutable: if one character could change "its" style, it would change it for everyone.

The everyday version is Interning: keeping one copy of each distinct string. Imagine a log processor holding 10 million parsed log lines, each with a region field that's one of four values like "us-east-1". Without interning, that's 10 million separate copies of the bytes. Go 1.23 added the unique package for exactly this:

import "unique"

type LogLine struct {
    Region unique.Handle[string] // ← a small pointer to the ONE shared copy
    Msg    string
}

h1 := unique.Make("us-east-1")
h2 := unique.Make(strings.ToLower("US-EAST-1")) // built separately, same contents
fmt.Println(h1 == h2)                          // true — compares one pointer, not 9 bytes
fmt.Println(h1.Value())                        // "us-east-1"

Here's what that saves, roughly:

Flyweight: one shared copy
Memory used by just the Region field of N log lines. Approximate, 64-bit Go.
a string per line
line 1us-east-1own copy
line 2us-east-1own copy
line 3eu-west-1own copy
line 4us-east-1own copy
line 5eu-west-1own copy
line 6us-east-1own copy
unique.Handle per line
line 1→us-east-1 (the only copy)
line 2→eu-west-1 (the only copy)
line 3→same shared “eu-west-1”
line 4→same shared “us-east-1”
line 5→same shared “eu-west-1”
line 6→same shared “us-east-1”
1,000,000
own copies
32.0 MB
interned
8.0 MB
Each own copy costs ≈ 32 bytes (a 16-byte string header plus the 9 letters, rounded up to 16). A handle is one 8-byte pointer. About 4× less, and the gap grows with longer strings.

JavaScript engines already intern string literals and many short strings for you, so you rarely do it by hand. The JS version of a flyweight is usually a shared, frozen object: create const BOLD = Object.freeze({ weight: 700, color: "#fff" }) once and point many items at it, instead of creating a new style object per item.

Practitioner§10

Telling them apart (and spotting them together)

Four of these patterns compile to the same code: a type that holds another value and forwards calls to it. You can't tell them apart by syntax. You tell them apart by asking what the wrapper is for.

Which pattern is it?
Pick a wrapper. The five questions are asked in order; the first “yes” names it.
1.Holds many children of its own kind and combines their answers?no
2.Hides several different collaborators behind one new, simpler method?no
3.Outside interface differs from the inside one, to make it fit?YES → Adapter
4.Same interface, and might NOT call the real thing?—
5.Same interface, always calls through, adds something around it?—
Adapter. The AWS SDK's PutObject doesn't match BlobStore.Save; the wrapper translates one into the other.

Five questions, in order

  1. Does it hold many children of its own kind and combine their answers? → Composite.
  2. Does it hide several different collaborators behind one new, simpler method? → Facade.
  3. Is its outside interface different from the inside one, to make it fit? → Adapter.
  4. Same interface, and might it not call the real thing (cache hit, denied, not created yet, over the network)? → Proxy.
  5. Same interface, always calls through, but adds something around the call? → Decorator.

All five in one small server

Real programs mix them freely. Here is a Go HTTP server of about 20 lines, and every structural pattern on this page is in it:

main.go
func main() {
    users := NewCachingUserStore(                       // ① PROXY: same UserStore interface,
        NewPostgresUserStore(db))                        //    skips the DB on a cache hit

    invoices := &InvoiceService{                        // ② FACADE: one method over
        orders: orders, pdf: pdf, storage: s3, mail: ses, //    four subsystems
    }

    api := http.NewServeMux()
    api.Handle("GET /users/{id}", getUserHandler(users))
    api.Handle("POST /invoices/{id}/send", sendInvoiceHandler(invoices))

    root := http.NewServeMux()                          // ③ COMPOSITE: a router holding
    root.Handle("/api/", http.StripPrefix("/api",        //    a router, both http.Handlers
        Chain(api, Authenticate)))                      //    (auth only for the API)
    root.Handle("GET /healthz", http.HandlerFunc(health)) // ④ ADAPTER: plain func → Handler
                                                         //    (no auth: load balancers call it)

    app := Chain(root, Recover, Logging)                // ⑤ DECORATORS: middleware for everything
    log.Fatal(http.ListenAndServe(":8080", app))
}

Read it once more and notice what the patterns buy: main is the only place that knows how things are wired together. Every other piece talks to an interface. That's the real payoff of structural patterns: not the names, but the freedom to swap, add and test pieces without touching their neighbors. (Building programs this way is the subject of Dependency Injection.)

Staff-level§11

Wrappers in production

Wrappers are cheap to write and easy to stack, which is exactly why production systems end up with ten of them between the network and your code. Here's what goes wrong at that point.

1. The wrapper that hides what's underneath

Logging middleware wants the response's status code. http.ResponseWriter has no "get status" method, so everyone writes the same small decorator that remembers it:

status.go
type statusRecorder struct {
    http.ResponseWriter       // ← embedded: forwards Header, Write, WriteHeader automatically
    status int
}

func (r *statusRecorder) WriteHeader(code int) {
    r.status = code                     // remember it…
    r.ResponseWriter.WriteHeader(code)  // …and forward
}

// in Logging: rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
//             next.ServeHTTP(rec, r)

Here's the catch. The w that Go's server passes you is really a *http.response, and it has more methods than the interface lists: Flush() (push buffered bytes to the client now), Hijack() (take over the raw connection), and others. Code that needs them checks with a Type assertion:

if f, ok := w.(http.Flusher); ok { // "does this writer ALSO have Flush()?"
    f.Flush()
}

Embedding http.ResponseWriter only promotes the three interface methods. The extra ones are gone. After your logging middleware wraps w, that check returns ok == false, and silently:

  • a Streaming response endpoint (live logs, Server-sent events, AI token streams) buffers everything and the user sees nothing until the very end;
  • a WebSocket upgrade fails, because the library can't Hijack the connection.

Nobody gets an error. The feature just stops working, in production, the day someone adds a middleware.

Where did Flush go?
A streaming handler sends five chunks and flushes after each one. Change the middleware wrapper and the way the handler flushes.
Logging middleware passes the handler…
The handler flushes with…
methods on the value the handler gets
Header()Write()WriteHeader()Flush()Hijack()Unwrap()
The real writer underneath still has Flush and Hijack, but the wrapper doesn't show them.
if f, ok := w.(http.Flusher); ok {
    f.Flush()
}
what the browser sees
press ▶
✕ w.(http.Flusher) → ok = false. Embedding only promoted the three interface methods; Flush is gone. No error, no log — everything just sits in the buffer until the handler returns.

The fix, Go 1.20+: give your wrapper an Unwrap() method, and have code use http.ResponseController instead of type assertions. The controller calls Unwrap() repeatedly, peeling off wrappers until it finds one that has the method:

func (r *statusRecorder) Unwrap() http.ResponseWriter { // ← "here's what I wrap"
    return r.ResponseWriter
}

// In the streaming handler, instead of w.(http.Flusher):
rc := http.NewResponseController(w)
if err := rc.Flush(); err != nil { // ← finds Flush through any number of Unwrap layers
    // errors.Is(err, http.ErrNotSupported) if nothing underneath can flush
}

http.ResponseController also exposes Hijack, SetReadDeadline, SetWriteDeadline and (Go 1.21+) EnableFullDuplex the same way. The same "wrapper + Unwrap" convention is what errors.Is and errors.As use to see through wrapped errors, so it's worth recognizing as a general Go idiom.

2. Context must flow through every layer

Each middleware receives a request whose context.Context is cancelled when the client disconnects or the deadline passes. A layer that builds a fresh context.Background() instead of deriving from r.Context() cuts the cancellation wire: everything inside it keeps querying databases for a client that left long ago.

✕ Don't
// inside a middleware
ctx := context.WithValue(context.Background(), userKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx)) // lost: cancellation + deadline
✓ Do
// inside a middleware
ctx := context.WithValue(r.Context(), userKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx)) // keeps both, adds the user

The JavaScript equivalent is passing the request's AbortSignal down through every layer (see Async / Await).

3. Ordering bugs

Middleware order is code, not configuration, and it's where most real middleware bugs live:

OrderWhat goes wrong
Body-logging outside any redactionLogs request bodies (sign-up forms, password changes, payment details) before anything strips the secrets, so passwords end up in the logs. Log bodies rarely, and only after redacting secret fields.
Recover inside loggingA panic / recover in logging itself escapes your recover: the client gets no 500 and you get no structured log line. Go's server only prints http: panic serving … and drops the connection. Recover goes outermost.
CORS inside authThe browser's Preflight request OPTIONS request carries no credentials, gets 401, and every cross-origin call fails. CORS goes outside auth.
Rate limit inside authAn attacker guessing API tokens sends thousands of requests with made-up tokens. Each one fails auth and returns before reaching the limiter, so the guessing is never slowed down. Limit by IP before auth (and per user after, if needed).
Body-reading middleware forgets to put the body backr.Body is an io.Reader: once read, it's empty. The handler gets an empty body. Read it, then set r.Body = io.NopCloser(bytes.NewReader(buf)).

4. What wrapping costs

Honest numbers, approximate and machine-dependent:

  • CPU: each layer is one extra function call through an interface, a few nanoseconds. Ten middleware layers add well under a microsecond to a request that spends milliseconds on the network. Not worth thinking about, except inside tight loops: a reader decorator called once per byte, millions of times, is a real cost.
  • Memory: the middleware closures are built once at startup. But per-request wrappers (&statusRecorder{…}, r.WithContext(ctx) copying the request) allocate on every request, which shows up in garbage-collector work at tens of thousands of requests per second.
  • Debuggability: a panic in a handler now prints 30 lines of ServeHTTP frames before your code. And "who returned this 403?" has ten possible answers. Name your middleware functions (not anonymous closures), log at every short-circuit, and if you use Tracing / span, give each layer its own span.
Practitioner§12

Pitfalls gallery

#1

The adapter that leaks the SDK

An adapter only protects you if none of the SDK's types cross it. Return the SDK's own struct through your interface and every caller now depends on the SDK again: switching providers means changing all of them.

✕ Don't
type PaymentGateway interface {
    // callers now import the stripeish package
    Charge(ctx context.Context, id string, cents int64) (*stripeish.PaymentIntent, error)
}
✓ Do
type PaymentGateway interface {
    // only our own types cross the boundary
    Charge(ctx context.Context, id string, cents int64) (receiptID string, err error)
}

The same goes for errors: translate the SDK's error types into your own (ErrDeclined) inside the adapter.

#2

Middleware that forgets to return

http.Error writes a response. It does not stop your function. Without return, the request carries straight on into the handler, unauthenticated.

✕ Don't
if !authorized(r) {
    http.Error(w, "forbidden", http.StatusForbidden)
}
next.ServeHTTP(w, r) // runs anyway!
✓ Do
if !authorized(r) {
    http.Error(w, "forbidden", http.StatusForbidden)
    return           // ← the short-circuit
}
next.ServeHTTP(w, r)

A tell-tale sign in your logs: http: superfluous response.WriteHeader call, meaning two layers both tried to write a status.

#3

A decorator that changes the numbers

Read(p) may return fewer bytes than len(p). A counting reader that adds len(p) instead of n over-counts every short read. More generally: a decorator must return exactly what the inner call returned (n, err) unless changing it is its whole job.

✕ Don't
func (c *CountingReader) Read(p []byte) (int, error) {
    n, err := c.r.Read(p)
    c.N += int64(len(p)) // wrong: p is the room, n is what was used
    return n, err
}
✓ Do
func (c *CountingReader) Read(p []byte) (int, error) {
    n, err := c.r.Read(p)
    c.N += int64(n)
    return n, err
}
#4

Handing out the cached object itself

A caching proxy that returns the same object to every caller lets one caller change the cache for everyone. In JS every object is shared by reference; in Go, structs are copied, but any slice, map or pointer inside them is still shared.

const user = await cachedStore.getUser(42);
user.name = user.name.toUpperCase(); // ← just changed the cached copy for every other request

Fixes: cache Immutable values (Object.freeze, or Go structs with no shared slices/maps), or return a copy (structuredClone(user)).

#5

Wrapper order chosen by accident

withRetry(withTiming(f)) logs one time per attempt; withTiming(withRetry(f)) logs the total. A counter below gzip counts compressed bytes; above gzip, plain bytes. Auth outside logging hides rejected requests from your logs. Every stack of wrappers has a meaning that depends on its order — decide it on purpose and write it down in a comment (§04, §05, §11).

#6

Composites that loop, or recompute forever

If a folder can (by mistake) contain itself, or a grandparent, Size() recurses until the program crashes: Go eventually dies with fatal error: stack overflow, JS throws RangeError: Maximum call stack size exceeded. Check for cycles when building trees from user data (a "parent ID" column in a database can point anywhere).

And because Size() walks the whole subtree every time, calling it on every request is slow for big trees. The usual fix is to store each folder's total and update it when a child changes, walking up the parent chain only.

#7

A pattern without a problem

UserServiceFacadeAdapterProxy wrapping one function that's only called from one place doesn't make code flexible. It makes it harder to read: every reader now has to open four files to find the one line that does something. Add a wrapper when you have a concrete second implementation, a real chore to factor out, or a test that needs a fake. Not before. (Anti-patterns has more on over-engineering.)

#8

A ResponseWriter wrapper without Unwrap

Covered in §11: a wrapper that embeds http.ResponseWriter hides Flush and Hijack, silently breaking streaming and WebSockets. Always add Unwrap() http.ResponseWriter, and use http.NewResponseController(w) rather than w.(http.Flusher).

Practitioner§13

Check yourself: which pattern is this?

Decide before you click. Q1–Q4 are about intent (§03–§10), Q5 is about middleware order (§05), Q6 is a production bug (§11).

QUESTION 1 / 6
Which pattern is this?
type S3Store struct {
    client *s3.Client
    bucket string
}

// Save satisfies our BlobStore interface.
func (s S3Store) Save(ctx context.Context, key string, data []byte) error {
    _, err := s.client.PutObject(ctx, &s3.PutObjectInput{
        Bucket: &s.bucket, Key: &key, Body: bytes.NewReader(data),
    })
    return err
}
QUESTION 2 / 6
Which pattern is this?
type limitedStore struct {
    next    Store
    limiter *rate.Limiter
}

func (s *limitedStore) Get(ctx context.Context, key string) ([]byte, error) {
    if !s.limiter.Allow() {
        return nil, ErrTooManyRequests
    }
    return s.next.Get(ctx, key)
}
QUESTION 3 / 6
Which pattern is this?
class Menu {
  constructor(label, items = []) { this.label = label; this.items = items; }
  render() {
    return `<li>${this.label}<ul>${this.items.map((i) => i.render()).join("")}</ul></li>`;
  }
}
class MenuItem {
  constructor(label) { this.label = label; }
  render() { return `<li>${this.label}</li>`; }
}
new Menu("File", [new MenuItem("Open"), new Menu("Recent", [new MenuItem("a.txt")])]).render();
QUESTION 4 / 6
Which pattern is this?
func Gzip(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        if !strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
            next.ServeHTTP(w, r)
            return
        }
        gz := gzip.NewWriter(w)
        defer gz.Close()
        w.Header().Set("Content-Encoding", "gzip")
        next.ServeHTTP(gzipResponseWriter{ResponseWriter: w, w: gz}, r)
    })
}
QUESTION 5 / 6
What gets printed?
func A(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Println("A in"); next.ServeHTTP(w, r); fmt.Println("A out")
    })
}
func B(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        fmt.Println("B in")
        if r.Header.Get("X-Key") == "" { http.Error(w, "no key", 403); return }
        next.ServeHTTP(w, r); fmt.Println("B out")
    })
}
app := Chain(http.HandlerFunc(handler), A, B) // handler prints "handler"
// a request arrives WITHOUT an X-Key header
QUESTION 6 / 6
Production puzzle
// This server-sent events (SSE) endpoint streamed fine for a year.
func events(w http.ResponseWriter, r *http.Request) {
    f, ok := w.(http.Flusher)
    if !ok {
        http.Error(w, "streaming unsupported", http.StatusInternalServerError)
        return
    }
    // … write an event, f.Flush(), repeat …
}
// Today someone wrapped every route in a Logging middleware that passes
// &statusRecorder{ResponseWriter: w} (embedding, no other methods).
Answered 0 / 6
Foundations§14

Cheat sheet

🔌
Adapter: make it fitWraps one object, exposes a different interface you already depend on. Translates names, units and errors. Go: http.HandlerFunc. JS: util.promisify.
📱
Decorator: add, keep the shapeSame interface, always calls through, adds behavior around the call. Stackable. Go: io.Reader wrappers. JS: withRetry(fn).
🧅
Middleware = decoratorfunc(http.Handler) http.Handler. Outermost runs first and last. Short-circuit by not calling next (and return!).
🛎️
Proxy: control accessSame interface, may not call through: cache, lazy, remote (gRPC stub), auth, rate limit. JS: new Proxy(target, traps).
🏨
Facade: one simple frontOne new, small method over many collaborators. Watch for god facades: one sentence, no "and".
📁
Composite: one = manyGroups and items share an interface; groups ask their children and combine. Folders, React, nested routers.
🌉
BridgeTwo dimensions that vary independently → hold one as an interface field. Smell: UrgentEmail, DigestEmail, UrgentSms…
🪶
FlyweightShare one copy of immutable data. Go 1.23+: unique.Make. Only after measuring memory.
If you see…It's probably…
A type wrapping an SDK client, exposing your interfaceAdapter
func(next http.Handler) http.HandlerMiddleware: a decorator, or a proxy if it can short-circuit
NewXxxReader(r io.Reader) io.ReaderDecorator
Same interface, a map and "if cached, return early"Caching proxy
sync.OnceValues(…) behind a getterLazy proxy
A generated client whose methods make network callsRemote proxy
One method calling 4+ different dependencies in sequenceFacade
A method that loops over children calling the same methodComposite
Production ruleWhy
Wrappers of http.ResponseWriter get Unwrap()So http.ResponseController can still find Flush / Hijack
Derive contexts from r.Context() in middlewareKeeps cancellation and deadlines flowing inward
Order: recover → request ID → log → rate limit → CORS → auth → handlerOuter layers see everything; cheap checks before expensive work
Don't log bodies outside authPasswords and tokens end up in logs
A wrapper needs a reasonA second implementation, a cross-cutting chore, or a test fake
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Design PatternsBehavioral Patterns
Read →