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.
The 30-second version
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 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:
| Pattern | The wrapper's outside looks like… | Why you wrap |
|---|---|---|
| Adapter | a different interface from the inside | to make something fit an interface it wasn't built for |
| Decorator | the same interface as the inside | to add behavior (logging, compression, counting) |
| Proxy | the same interface as the inside | to control access (cache, check, delay, send over the network) |
| Facade | a smaller, simpler interface over many insides | to hide a complicated subsystem |
| Composite | the same interface as each child | to 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.
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 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 fullNotice 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.
Adapter: make it fit
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:
// 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:
// 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:
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:
Names and shapes
Charge(id, cents) becomes CreatePaymentIntent(params{…}) or Pay(customer, amount).Units and types
Errors and results
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:
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/catchNode 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.)
Decorator: add behavior, keep the shape
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:
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:
| Wrapper | Takes | Gives back | Adds |
|---|---|---|---|
bufio.NewReader(r) | a reader | a reader | a Buffer / buffered reader: reads 4 KB at a time from r, serves your small reads from memory |
gzip.NewReader(r) | a reader of compressed bytes | a reader of plain bytes | gzip decompression |
io.TeeReader(r, w) | a reader + a writer | a reader | copies every byte it reads into w (like a T-junction in a pipe) |
io.LimitReader(r, n) | a reader | a reader | stops 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:
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 contentsThe 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:
// 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 rDecorators 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:
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 retriesSwap 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.
HTTP middleware: decorators for handlers
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:
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.
Step through a real request. Flip the token switch to see what short-circuiting looks like:
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}78func 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}1617func 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 return23 }24 next.ServeHTTP(w, r.WithContext(withUser(r.Context(), user)))25 })26}2728app := Chain(http.HandlerFunc(listOrders), RequestID, Logging, Auth)
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.Three facts fall out of the onion, and every one of them causes real bugs when forgotten:
Outermost runs first and last
Short-circuits skip the inside
Inner layers can add data for later ones
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.
Proxy: same interface, controlled access
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:
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:
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
Remote proxy
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
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, instantlyJavaScript'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.
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:
| Decorator | Proxy | |
|---|---|---|
| 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 layers | Often hidden: a factory or library hands you the proxy and you don't know |
| Examples | logging, timing, compression, metrics | cache, 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.
Facade: one simple front
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.
// 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
InvoiceServicechanges: 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:
| Adapter | Facade | |
|---|---|---|
| Wraps | one object | many objects |
| Its outside interface | one that already exists (your PaymentGateway) | a new, smaller one invented for the job |
| Goal | make it fit | make 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.)
Composite: groups that act like one
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.
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()) // 3068The 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:
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
<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
element.textContent on a <div> gathers the text of everything inside it — a composite operation, just like Size().Nested HTTP routers
http.ServeMux) is itself an http.Handler, so a router can hold another router as one of its routes (below).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" → getUserBecause 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.
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.
Channel
────▶
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 runtimeIf 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:
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.
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.
Five questions, in order
- Does it hold many children of its own kind and combine their answers? → Composite.
- Does it hide several different collaborators behind one new, simpler method? → Facade.
- Is its outside interface different from the inside one, to make it fit? → Adapter.
- Same interface, and might it not call the real thing (cache hit, denied, not created yet, over the network)? → Proxy.
- 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:
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.)
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:
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
Hijackthe connection.
Nobody gets an error. The feature just stops working, in production, the day someone adds a middleware.
if f, ok := w.(http.Flusher); ok {
f.Flush()
}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.
// inside a middleware
ctx := context.WithValue(context.Background(), userKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx)) // lost: cancellation + deadline// inside a middleware
ctx := context.WithValue(r.Context(), userKey{}, user)
next.ServeHTTP(w, r.WithContext(ctx)) // keeps both, adds the userThe 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:
| Order | What goes wrong |
|---|---|
| Body-logging outside any redaction | Logs 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 logging | A 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 auth | The browser's Preflight request OPTIONS request carries no credentials, gets 401, and every cross-origin call fails. CORS goes outside auth. |
| Rate limit inside auth | An 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 back | r.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
ServeHTTPframes 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.
Pitfalls gallery
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.
type PaymentGateway interface {
// callers now import the stripeish package
Charge(ctx context.Context, id string, cents int64) (*stripeish.PaymentIntent, error)
}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.
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.
if !authorized(r) {
http.Error(w, "forbidden", http.StatusForbidden)
}
next.ServeHTTP(w, r) // runs anyway!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.
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.
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
}func (c *CountingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.N += int64(n)
return n, err
}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 requestFixes: cache Immutable values (Object.freeze, or Go structs with no shared slices/maps), or return a copy (structuredClone(user)).
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).
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.
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.)
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).
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).
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
}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)
}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();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)
})
}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// 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).Cheat sheet
http.HandlerFunc. JS: util.promisify.io.Reader wrappers. JS: withRetry(fn).func(http.Handler) http.Handler. Outermost runs first and last. Short-circuit by not calling next (and return!).new Proxy(target, traps).UrgentEmail, DigestEmail, UrgentSms…unique.Make. Only after measuring memory.| If you see… | It's probably… |
|---|---|
| A type wrapping an SDK client, exposing your interface | Adapter |
func(next http.Handler) http.Handler | Middleware: a decorator, or a proxy if it can short-circuit |
NewXxxReader(r io.Reader) io.Reader | Decorator |
Same interface, a map and "if cached, return early" | Caching proxy |
sync.OnceValues(…) behind a getter | Lazy proxy |
| A generated client whose methods make network calls | Remote proxy |
| One method calling 4+ different dependencies in sequence | Facade |
| A method that loops over children calling the same method | Composite |
| Production rule | Why |
|---|---|
Wrappers of http.ResponseWriter get Unwrap() | So http.ResponseController can still find Flush / Hijack |
Derive contexts from r.Context() in middleware | Keeps cancellation and deadlines flowing inward |
| Order: recover → request ID → log → rate limit → CORS → auth → handler | Outer layers see everything; cheap checks before expensive work |
| Don't log bodies outside auth | Passwords and tokens end up in logs |
| A wrapper needs a reason | A second implementation, a cross-cutting chore, or a test fake |