Strategy Pattern — Middle¶
1. What this level adds¶
Junior taught the shape: an interface or a function, swapped at runtime. Middle is about the design judgement that surrounds it:
- The interface-and-function-adapter trick (
http.HandlerFuncpattern) — getting both shapes for one role. - Strategy registries — looking strategies up by name.
- Composing strategies — chains, fallbacks, weighted selection.
- Generic strategies (Go 1.18+) and when they actually help.
- Testing strategies cleanly — mocking, the "real implementation in tests" alternative.
- Strategy lifetime — long-lived vs per-request, and the allocation cost of each.
- The nil-interface trap, the typed-nil trap, and other production bugs.
- Strategy ↔ Decorator interop and the difference under pressure.
By the end you should be able to design a strategy-based subsystem without backtracking.
2. Table of Contents¶
- What this level adds
- Table of Contents
- Interface segregation in practice
- The interface + function adapter (HandlerFunc pattern)
- Strategy registries — naming and lookup
- Composing strategies — chains, fallbacks, weighted
- Generic strategies
- Strategy lifetime and allocation
- Testing — mocks, fakes, and the in-memory implementation
- Nil-interface and typed-nil traps
- Strategy ↔ Decorator interop
- Coding patterns
- Performance notes
- Common middle-level mistakes
- Debugging a strategy bug
- Tricky points
- Test
- Cheat sheet
- Summary
3. Interface segregation in practice¶
A common mid-level realisation: the right number of methods on a strategy interface is almost always smaller than you think.
// Tempting — model the whole "payment provider" capability
type Gateway interface {
Charge(...) error
Refund(...) error
Capture(...) error
Authorize(...) error
Webhook(...) error
}
Now ask: does the Processor actually call all five? If Processor only calls Charge, the interface should only have Charge. Implementations that also support refund expose Refund on the concrete type — callers who need refund accept a Refunder interface.
type Charger interface { Charge(...) error }
type Refunder interface { Refund(...) error }
type Authorizer interface { Authorize(...) error }
type StripeGateway struct{ /* ... */ }
func (s *StripeGateway) Charge(...) error { /* ... */ }
func (s *StripeGateway) Refund(...) error { /* ... */ }
func (s *StripeGateway) Authorize(...) error { /* ... */ }
// Stripe satisfies all three. Square may satisfy only Charge.
The consumer picks the narrowest interface it needs:
This is interface segregation. The benefit isn't theoretical — it shows up the moment a new gateway joins that doesn't support refund. With the wide interface, you'd either fail the type check or add a Refund() that returns "not supported" (a code smell). With segregation, the gateway simply doesn't satisfy Refunder, and the system tells you about that constraint at compile time.
How to detect "interface too wide"¶
- Implementations have methods that return "not supported" /
errors.New("unimplemented"). - Some implementations have stub methods with TODOs.
- Tests need to mock methods the test isn't exercising.
- The interface has more methods than any single caller uses.
When you see these, split the interface.
4. The interface + function adapter (HandlerFunc pattern)¶
The canonical Go idiom for "accept either an interface or a function with the same signature".
// from net/http
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}
type HandlerFunc func(ResponseWriter, *Request)
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r) // the adapter just invokes the function
}
The trick: HandlerFunc is a named function type, and named types can have methods. HandlerFunc.ServeHTTP calls the function — so any HandlerFunc value satisfies Handler.
Why this matters: callers get the best of both shapes.
mux := http.NewServeMux()
mux.Handle("/users", &userHandler{}) // interface form
mux.Handle("/posts", http.HandlerFunc(handlePost)) // function form via adapter
mux.HandleFunc("/comments", handleComment) // function form via shortcut
Define your own when you're publishing a strategy API:
package payment
type Charger interface {
Charge(ctx context.Context, amount int) error
}
type ChargeFunc func(ctx context.Context, amount int) error
func (f ChargeFunc) Charge(ctx context.Context, amount int) error {
return f(ctx, amount)
}
Now consumers can write either:
NewProcessor(&StripeGateway{...})
NewProcessor(payment.ChargeFunc(func(ctx context.Context, amount int) error {
// inline implementation, often for tests
return nil
}))
The adapter type is ~five lines, costs nothing at runtime (the method call boils down to a direct function call), and gives your API users a strictly better experience.
When NOT to define the adapter¶
- The strategy has multiple methods. You can't adapter-ize an interface with five methods cleanly — you'd need a struct with five fields and method shims, which is more work than just writing a struct.
- The strategy's call site is rare. Adding the adapter is permanent surface area; don't add it speculatively.
5. Strategy registries — naming and lookup¶
Sometimes the strategy is chosen by name at runtime (config file, CLI flag, request payload). The pattern:
package codec
type Codec interface {
Encode([]byte) []byte
Decode([]byte) ([]byte, error)
}
var registry = map[string]Codec{}
func Register(name string, c Codec) {
if _, dup := registry[name]; dup {
panic("codec: " + name + " already registered")
}
registry[name] = c
}
func Get(name string) (Codec, error) {
c, ok := registry[name]
if !ok {
return nil, fmt.Errorf("codec: unknown %q", name)
}
return c, nil
}
Each implementation registers itself in init():
Importing codec_gzip for its side effects (blank import: _ "myorg/codec_gzip") makes the codec available at runtime.
Pros / cons of the registry pattern¶
Pros: - Configuration-driven. The strategy comes from a string, not a hardcoded switch. - Plugins. Third parties register their codecs without modifying core code. - Discoverable. Reading the init() of a package tells you what it provides.
Cons: - Global mutable state. Order of imports matters for what's registered when. - Hard to test in isolation (the registry persists across tests; one test registering a mock affects others). - Init-time side effects can mask errors (a duplicate registration panics during init, far from the code that caused it).
The standard library uses this pattern (database/sql.Register, image.RegisterFormat, compress/gzip driver pattern). For application code, the cleaner alternative is explicit construction:
Use the registry pattern only when configuration-driven selection is a hard requirement. Otherwise the explicit form is easier to test and reason about.
Variant: typed registries¶
type CodecFactory func() Codec
var registry = map[string]CodecFactory{}
func Register(name string, f CodecFactory) { registry[name] = f }
func New(name string) (Codec, error) {
f, ok := registry[name]
if !ok { return nil, errors.New("unknown codec") }
return f(), nil
}
Factories instead of instances. Each Get returns a fresh strategy. Useful when the strategy has state that shouldn't be shared (e.g., a stateful compressor with internal buffers).
6. Composing strategies — chains, fallbacks, weighted¶
A []Strategy plus a loop covers most composition needs. Three named compositions are worth knowing.
6.1 Chain — first non-nil result wins¶
type Resolver interface {
Resolve(name string) (string, error)
}
type ChainResolver []Resolver
func (cs ChainResolver) Resolve(name string) (string, error) {
var lastErr error
for _, r := range cs {
v, err := r.Resolve(name)
if err == nil {
return v, nil
}
lastErr = err
}
return "", fmt.Errorf("ChainResolver: all failed: %w", lastErr)
}
Try localCacheResolver first; if it fails, try dnsResolver; if that fails, return the last error. The chain is a strategy — ChainResolver satisfies Resolver itself, so it composes with other strategies.
6.2 Fallback — error from primary triggers backup¶
type FallbackResolver struct {
Primary, Backup Resolver
}
func (f FallbackResolver) Resolve(name string) (string, error) {
v, err := f.Primary.Resolve(name)
if err == nil { return v, nil }
return f.Backup.Resolve(name)
}
Two-strategy chain, but the structure makes the relationship explicit. Useful when readers should immediately see "primary + backup".
6.3 Weighted / round-robin¶
type WeightedRouter struct {
routes []Strategy
weights []int
rnd *rand.Rand
}
func (w *WeightedRouter) Pick() Strategy {
total := 0
for _, x := range w.weights { total += x }
pick := w.rnd.Intn(total)
for i, x := range w.weights {
pick -= x
if pick < 0 { return w.routes[i] }
}
return w.routes[len(w.routes)-1] // unreachable normally
}
For A/B tests, traffic shaping, canary rollout. The composition itself implements a strategy of "which underlying strategy to call".
6.4 The composition principle¶
Strategies satisfy interfaces. Compositions satisfy the same interface. You can stack them indefinitely without changing the interface contract:
var r Resolver = ChainResolver{
cacheResolver,
FallbackResolver{Primary: dnsResolver, Backup: hostsResolver},
}
Three levels of composition, one interface throughout. This is the practical payoff of small interfaces — composability is free.
7. Generic strategies¶
Go 1.18+ generics let you write a strategy that adapts to the data type.
type Reducer[T, R any] func(acc R, item T) R
func Reduce[T, R any](items []T, init R, fn Reducer[T, R]) R {
acc := init
for _, it := range items {
acc = fn(acc, it)
}
return acc
}
// Usage:
sum := Reduce([]int{1, 2, 3}, 0, func(a, b int) int { return a + b })
maxName := Reduce(users, "", func(s string, u User) string {
if u.Name > s { return u.Name }
return s
})
The generic Reducer[T, R] is a strategy parameterised by data type. Before generics you'd write Reduce(items []int, ...), then Reduce(items []string, ...), then Reduce(items []User, ...), all near-duplicates. Now one signature handles them all.
When generic strategies help¶
- Operations on slices, maps, channels that are agnostic to element type (map/filter/reduce, sort, partition).
- Pipelines where multiple stages transform
TtoUtoV. - Cache implementations parameterised by key and value type.
When they don't help¶
- A strategy that has business meaning specific to its domain.
Charge[T Currency]doesn't buy you anything overCharge— you almost never call it with two currencies in one process. - An interface with multiple methods. Go's generics on methods are limited; you can't have a method like
func (s Strategy[T]) Do(t T)declared on a non-generic interface. - A strategy that needs to introspect its own type. Use
reflectinstead.
Generics excel when the strategy is structurally polymorphic. They flounder when it's domain polymorphic.
8. Strategy lifetime and allocation¶
Where does the strategy live?
8.1 Long-lived, set once at startup¶
processor := NewProcessor(&StripeGateway{apiKey: cfg.StripeKey})
// processor.gateway is set once; never changes for the life of the process
Most strategies are this. One allocation at boot. Performance is irrelevant.
8.2 Per-request, varying¶
func handleCharge(w http.ResponseWriter, r *http.Request) {
g := gatewayByCountry(r.Header.Get("X-Country"))
p := NewProcessor(g)
p.Process(r.Context(), order)
}
A fresh strategy per request. Allocation cost depends on the gateway: usually low (a struct with a few pointers).
If gatewayByCountry returns cached instances, this is the same as §8.1. If it constructs fresh ones, profile to see if the allocation matters.
8.3 Hot-path, called millions of times¶
The closure is allocated once (Go is smart enough to recognise the closure doesn't escape the call), so this is zero-allocation. But:
func sortBy(field string) func(int, int) bool {
return func(i, j int) bool {
// closure captures `field` — allocation
switch field { /* ... */ }
return false
}
}
Each call to sortBy allocates a new closure. Fine if called twice per request; bad if called per-element.
8.4 Interface vs function — does it matter?¶
A direct function call is ~1 ns. An interface call adds ~1-2 ns for the indirect dispatch. For a per-request strategy this is invisible; for a per-element strategy in a tight loop it could matter.
BenchmarkFunctionStrategy-8 1000000000 0.85 ns/op 0 B/op
BenchmarkInterfaceStrategy-8 700000000 1.62 ns/op 0 B/op
If profiling shows the strategy dispatch as a hot path, replace the interface with a function. Otherwise don't worry.
9. Testing — mocks, fakes, and the in-memory implementation¶
Strategies are wonderful for tests. Three approaches.
9.1 Manual mock¶
type mockGateway struct {
chargeCalled bool
chargeArgs struct { amount int; currency string }
chargeReturn struct { id string; err error }
}
func (m *mockGateway) Charge(ctx context.Context, amount int, ccy string) (string, error) {
m.chargeCalled = true
m.chargeArgs.amount = amount
m.chargeArgs.currency = ccy
return m.chargeReturn.id, m.chargeReturn.err
}
func TestProcessor_Process(t *testing.T) {
g := &mockGateway{}
g.chargeReturn.id = "test_123"
p := NewProcessor(g)
id, err := p.Process(ctx, Order{AmountCents: 1000, Currency: "USD"})
if err != nil { t.Fatal(err) }
if !g.chargeCalled { t.Error("Charge not called") }
if g.chargeArgs.amount != 1000 { t.Errorf("amount = %d", g.chargeArgs.amount) }
if id != "test_123" { t.Errorf("id = %q", id) }
}
Direct and clear. Easy to read, no magic.
9.2 Function adapter for one-off stubs¶
func TestProcessor_Process(t *testing.T) {
var captured int
p := NewProcessor(payment.ChargeFunc(func(ctx context.Context, amount int, _ string) (string, error) {
captured = amount
return "test_123", nil
}))
p.Process(ctx, Order{AmountCents: 1000, Currency: "USD"})
if captured != 1000 { t.Errorf("amount = %d", captured) }
}
Useful for ad-hoc behaviour where defining a whole mock type is overkill.
9.3 The in-memory implementation¶
package payment
// InMemoryGateway records charges in a map. Production-quality enough for tests.
type InMemoryGateway struct {
mu sync.Mutex
charges map[string]Order // id -> order
}
func NewInMemoryGateway() *InMemoryGateway {
return &InMemoryGateway{charges: make(map[string]Order)}
}
func (g *InMemoryGateway) Charge(_ context.Context, amount int, ccy string) (string, error) {
g.mu.Lock()
defer g.mu.Unlock()
id := fmt.Sprintf("mem_%d", len(g.charges))
g.charges[id] = Order{AmountCents: amount, Currency: ccy}
return id, nil
}
func (g *InMemoryGateway) Get(id string) (Order, bool) {
g.mu.Lock()
defer g.mu.Unlock()
o, ok := g.charges[id]
return o, ok
}
A real-behaviour fake. Used in: - Unit tests where mocks would be too brittle. - Integration tests where the real gateway is slow / costs money. - End-to-end tests when running against staging infrastructure isn't possible.
The in-memory implementation pays off when the real behaviour is non-trivial. For a payment gateway, the fake validates that "charge then refund" gives the right balance — something a mock can't easily express.
10. Nil-interface and typed-nil traps¶
The Go gotcha that catches everyone at least once.
type Charger interface { Charge() error }
type StripeGateway struct{ /* ... */ }
func (s *StripeGateway) Charge() error { return nil }
func newGateway(useStripe bool) Charger {
var sg *StripeGateway
if useStripe {
sg = &StripeGateway{}
}
return sg // !
}
func main() {
g := newGateway(false)
if g == nil {
fmt.Println("no gateway")
} else {
g.Charge() // panic: nil pointer dereference
}
}
What happened: newGateway(false) returns (type=*StripeGateway, value=nil) — a non-nil interface wrapping a nil pointer. The check g == nil compares the interface against the typeless nil interface. They're not equal — the returned interface has a type.
Fix:
func newGateway(useStripe bool) Charger {
if useStripe {
return &StripeGateway{}
}
return nil // returns a typeless nil interface
}
Or even cleaner — never assign *T to a variable typed as the interface unless the *T is non-nil.
Rule¶
When a function returns an interface, return a typeless nil when there's no value. Don't return a nilable concrete pointer; the interface will wrap the nil and lie about being non-nil.
We come back to this in professional.md §6 with the runtime details (the iface struct).
11. Strategy ↔ Decorator interop¶
Decorator wraps a strategy with cross-cutting concerns. The decorator implements the same interface as the strategy and delegates.
type Charger interface { Charge(ctx context.Context, amount int) error }
// LoggingCharger decorates a Charger with logging.
type LoggingCharger struct {
Inner Charger
Log *log.Logger
}
func (l *LoggingCharger) Charge(ctx context.Context, amount int) error {
l.Log.Printf("Charge: amount=%d", amount)
err := l.Inner.Charge(ctx, amount)
if err != nil { l.Log.Printf("Charge failed: %v", err) }
return err
}
// RetryingCharger decorates a Charger with retries.
type RetryingCharger struct {
Inner Charger
Attempts int
}
func (r *RetryingCharger) Charge(ctx context.Context, amount int) error {
var err error
for i := 0; i < r.Attempts; i++ {
if err = r.Inner.Charge(ctx, amount); err == nil { return nil }
}
return fmt.Errorf("after %d attempts: %w", r.Attempts, err)
}
Compose:
var c Charger = &StripeGateway{...}
c = &RetryingCharger{Inner: c, Attempts: 3}
c = &LoggingCharger{Inner: c, Log: logger}
// c is now: Logging( Retrying( Stripe ) )
The strategies and the decorators implement Charger. The chain is invisible to the consumer (Processor); it just calls c.Charge(...).
This works because Go interfaces don't have identity — any value satisfying the interface fits. Java does the same with the Decorator pattern. The trick is identical; only the syntax differs.
We cover Decorator in detail in ../04-decorator-pattern/. The point here is: Strategy and Decorator pair natively in Go because they share the same interface contract.
12. Coding patterns¶
12.1 The default strategy¶
func NewProcessor(g Gateway) *Processor {
if g == nil {
g = &noopGateway{} // never panics, returns "not configured" errors
}
return &Processor{gateway: g}
}
Internal noopGateway keeps the processor usable for tests that don't care about charging. Alternative: return an error from NewProcessor.
12.2 The "must" strategy¶
type MustCharger struct{ Inner Charger }
func (m MustCharger) Charge(ctx context.Context, amount int) error {
if err := m.Inner.Charge(ctx, amount); err != nil {
panic(fmt.Errorf("MustCharge: %w", err))
}
return nil
}
Wraps a Charger so any error becomes a panic. Useful only when an unhandled charge error should never happen (e.g., in a controlled test environment). In production code, prefer surfacing the error.
12.3 The introspectable strategy¶
type Named interface { Name() string }
func describe(c Charger) string {
if n, ok := c.(Named); ok {
return n.Name()
}
return "anonymous"
}
Optional interfaces let strategies expose extra information when supported. Avoid making Name() mandatory on Charger; use a separate optional interface and a type assertion.
12.4 The constraint check via type assertion¶
When the consumer can use richer behaviour if available, type-assert. The strategy doesn't have to declare anything special — interface satisfaction is structural.
13. Performance notes¶
BenchmarkDirectCall-8 1000000000 0.85 ns/op 0 B/op 0 allocs/op
BenchmarkFunctionStrategy-8 1000000000 0.91 ns/op 0 B/op 0 allocs/op
BenchmarkInterfaceStrategy-8 700000000 1.62 ns/op 0 B/op 0 allocs/op
BenchmarkClosureCapture-8 500000000 2.10 ns/op 16 B/op 1 allocs/op
Observations:
- Direct call (no strategy) is the floor.
- Function strategy is essentially free — Go inlines simple closures aggressively. The difference vs direct call is noise.
- Interface strategy adds ~0.8 ns per call (indirect dispatch via the interface table). Imperceptible for non-hot-path code.
- Closure capture that escapes to the heap costs an allocation. This is not the cost of the strategy pattern; it's the cost of creating a new strategy that captures state. If you reuse the closure (build once, call millions of times), the alloc is amortised.
What this means in practice¶
- For boot-time strategies (chosen once): no perf cost worth measuring.
- For per-request strategies: interface dispatch is fine.
- For per-element strategies in tight inner loops: prefer functions or, if you're really hot, inline the operation.
You will almost never need to switch strategies for performance. When you do, profile first — guesses about strategy cost are usually wrong by an order of magnitude in either direction.
14. Common middle-level mistakes¶
14.1 Naming the interface after the pattern¶
// Smell
type CompressStrategy interface { Compress([]byte) []byte }
// Idiomatic
type Compressor interface { Compress([]byte) []byte }
14.2 Mixing too many concerns into one interface¶
type Storage interface {
Read(key string) ([]byte, error)
Write(key string, value []byte) error
Delete(key string) error
List(prefix string) ([]string, error)
Lock(key string) error
Unlock(key string) error
Subscribe(prefix string, ch chan<- Event) error
}
This is "everything a storage system might do". Real consumers use 1-3 methods. Split into Reader, Writer, Locker, Subscriber.
14.3 Hiding strategy behind an enum¶
type GatewayKind int
const (
Stripe GatewayKind = iota
PayPal
Square
)
func Charge(kind GatewayKind, amount int) error {
switch kind {
case Stripe: /* ... */
case PayPal: /* ... */
case Square: /* ... */
}
}
The switch is the strategy, badly. Adding a new gateway means editing the function. Use an interface or function value instead.
14.4 Putting strategies in a slice as interface{}¶
Use the actual interface type:
interface{} (or any) loses type information. The compiler can't check what methods you can call.
15. Debugging a strategy bug¶
You suspect the wrong strategy ran. Walk through it.
15.1 Log the strategy's concrete type¶
log.Printf("Process: using gateway %T", p.gateway)
// Output: Process: using gateway *payment.StripeGateway
%T prints the concrete type wrapped by the interface. Tells you which strategy is in play. Common cause of mystery bugs: a test set up a different gateway than expected.
15.2 Verify in the constructor¶
func NewProcessor(g Gateway) *Processor {
log.Printf("NewProcessor: %T", g)
return &Processor{gateway: g}
}
If the constructor logs the wrong type, the caller is wrong. If the right type, the bug is elsewhere.
15.3 Use the expvar pattern for production diagnosis¶
import "expvar"
var gatewayName = expvar.NewString("payment.gateway")
func NewProcessor(g Gateway) *Processor {
if n, ok := g.(interface{ Name() string }); ok {
gatewayName.Set(n.Name())
} else {
gatewayName.Set(fmt.Sprintf("%T", g))
}
return &Processor{gateway: g}
}
Now /debug/vars shows which gateway the process is using — handy for verifying deploys.
16. Tricky points¶
16.1 The receiver kind matters¶
type StripeGateway struct{ ... }
func (s StripeGateway) Charge(...) error { ... } // value receiver
var g Gateway = StripeGateway{...} // ok — value satisfies the interface
var g Gateway = &StripeGateway{...} // also ok — pointer satisfies it too
vs.
type StripeGateway struct{ ... }
func (s *StripeGateway) Charge(...) error { ... } // pointer receiver
var g Gateway = StripeGateway{...} // compile error — value type doesn't satisfy
var g Gateway = &StripeGateway{...} // ok
Pointer-receiver methods are only on the pointer type's method set. Value-receiver methods are on both. Be consistent: pick one style per type. Mixing causes confusing "doesn't satisfy interface" errors.
16.2 Method values vs method expressions¶
g := &StripeGateway{...}
chargeMethod := g.Charge // method value — bound to g
result := chargeMethod(ctx, 100, "USD") // calls g.Charge(ctx, 100, "USD")
chargeExpr := (*StripeGateway).Charge // method expression — unbound
result = chargeExpr(g, ctx, 100, "USD") // explicit receiver
Method values are useful as one-off strategies — they capture the receiver. Method expressions are useful for plumbing — they work with any receiver of the type.
16.3 Returning an interface from a stub strategy¶
If something earlier set globalGateway = nil, this returns a typeless nil — caller's if g == nil works. If something set globalGateway = (*StripeGateway)(nil), this returns a typed nil — if g == nil is false but g.Charge(...) panics. The typed-nil trap, scaled to globals.
16.4 The empty interface as a strategy type¶
If you ever find yourself writing Strategy interface{} or any, you've defeated the type system. The strategy interface needs at least one method to be useful.
17. Test¶
Q1. What's wrong?
type Renderer interface {
Render(html string) string
Cache() *RenderCache
SetCache(c *RenderCache)
Logger() *log.Logger
SetLogger(l *log.Logger)
Metrics() *MetricsClient
SetMetrics(m *MetricsClient)
}
Answer
The interface has seven methods, six of which are getters/setters for unrelated dependencies. The consumer that calls `Render(html)` doesn't care about caches or loggers. Split: The cache, logger, and metrics belong in the concrete type's constructor, not the strategy interface.Q2. Why does this fail?
type Charger interface { Charge() error }
type StripeGateway struct{}
func (s *StripeGateway) Charge() error { return nil }
var c Charger
sg := (*StripeGateway)(nil)
c = sg
if c == nil {
fmt.Println("nil")
} else {
c.Charge()
}
Answer
Prints nothing, then panics. `c` is `(*StripeGateway, nil)` — a non-nil interface wrapping a nil pointer. `c == nil` is false. `c.Charge()` does a virtual dispatch to `(*StripeGateway).Charge`, but the receiver is nil — depending on `Charge`'s body, it either runs OK (if it doesn't deref the receiver) or panics. Here `Charge() error { return nil }` doesn't deref — so actually it returns nil with no panic. Surprising both ways. The fix: return typeless nil, not a typed nil pointer.Q3. Which is more idiomatic — and why?
// A
func handleSort(field string, items []User) {
var less func(i, j int) bool
switch field {
case "name": less = func(i, j int) bool { return items[i].Name < items[j].Name }
case "age": less = func(i, j int) bool { return items[i].Age < items[j].Age }
}
sort.Slice(items, less)
}
// B
func handleSort(field string, items []User) {
sort.Slice(items, lessByField(field, items))
}
func lessByField(field string, items []User) func(i, j int) bool {
switch field {
case "name": return func(i, j int) bool { return items[i].Name < items[j].Name }
case "age": return func(i, j int) bool { return items[i].Age < items[j].Age }
}
return nil
}
Answer
B. Same logic but the strategy construction is a separate function with a meaningful name. `lessByField` is testable in isolation; `handleSort` reads the high-level intent without the switch noise. A is fine for one-off code; B scales as more sort fields appear.18. Cheat sheet¶
| Situation | Approach |
|---|---|
| One method, fixed signature | Function type |
| Multiple methods or stateful | Interface |
| Both shapes for one role | Define interface + adapter named function type (like http.HandlerFunc) |
| Multiple capabilities, some optional | Segregate into multiple small interfaces; optional via type assertion |
| Name-based lookup | Registry + Register/Get |
| Composition | Chain, fallback, weighted — composition types themselves implement the strategy interface |
| Generic by data type | Strategy[T, R] with type parameters |
| Cross-cutting (logging, retry, caching) | Decorator pattern — wrapper implements the same interface |
| Testing | In-memory implementation, function-adapter stubs, mock structs — pick by complexity |
19. Summary¶
Strategy in Go is so deeply embedded in the language idioms that "doing Strategy" feels indistinguishable from "writing Go". The middle-level skill is knowing when to:
- Promote a function to an interface (when state or multiple operations appear).
- Add the interface + function adapter (when both call-site shapes matter).
- Use a registry vs explicit construction (when configuration-driven selection is a real need).
- Segregate a wide interface into narrow ones (when consumers diverge).
- Compose strategies via chains, fallbacks, weighted (when the combination is itself a strategy).
The next step is senior.md — architecture-level concerns: strategy in distributed systems, runtime strategy reload, strategy versioning across major releases, anti-patterns at scale, and case studies (crypto/cipher, database/sql drivers, compress/*, gRPC interceptors).