Behavioral Patterns in Go: Strategy, Observer, Iterator
You open a Go file and need to swap an algorithm at runtime. Your muscle memory screams "Strategy Pattern." You start typing class ConcreteStrategy implements Strategy. Then you pause. Go has no classes. There is no implements keyword. There is no hierarchy of abstract base classes. You feel a moment of friction. Where do the patterns go?
They don't disappear. They shrink until they fit in your pocket. Go does not ban patterns. It makes them so simple that you stop calling them patterns and start calling them code. The heavy machinery of pattern names vanishes. You are left with the mechanism. Strategy is just a function parameter. Observer is just a channel or a callback. Iterator is just range. You get the behavior without the tax.
Strategy: The function is the pattern
The Strategy pattern lets you swap algorithms dynamically. In languages with classes, you define an interface, create multiple implementations, and pass the right one to a context. In Go, you do the same thing, but the ceremony drops away. If the strategy is a single operation, you pass a function. If it involves multiple methods, you use a tiny interface.
Go's standard library is full of strategies. The sort package uses a function parameter for the comparison logic. You pass a less function to sort.Slice. That function is the strategy. You don't create a Comparator class. You write a function literal and hand it over.
// SortStrings sorts a slice of strings using the provided comparison function.
// The strategy is the function itself, passed as a parameter.
func SortStrings(items []string, less func(a, b string) bool) {
// Bubble sort for brevity. The algorithm is fixed; the strategy is pluggable.
for i := 0; i < len(items); i++ {
for j := i + 1; j < len(items); j++ {
if less(items[j], items[i]) {
items[i], items[j] = items[j], items[i]
}
}
}
}
func main() {
words := []string{"banana", "apple", "cherry"}
// Pass the strategy as a function literal.
// This sorts by length instead of alphabetical order.
SortStrings(words, func(a, b string) bool {
return len(a) < len(b)
})
}
When you need more than one method, you define an interface. Go convention favors small interfaces. One or two methods is the sweet spot. The community mantra is "accept interfaces, return structs." Your function accepts the interface, but it returns a concrete struct. This keeps the API flexible for callers while giving you a stable implementation.
Receiver naming follows a strict convention. The receiver name should be one or two letters matching the type. Use (s *StrategyImpl) Execute(), not (this *StrategyImpl) or (self *StrategyImpl). This keeps code scannable. The type name carries the meaning; the receiver name is just a handle.
// AuthStrategy defines how a request is authenticated.
// Single-method interfaces are idiomatic. The name often ends in er.
type AuthStrategy interface {
// Authenticate checks credentials and returns true if valid.
Authenticate(req *http.Request) bool
}
// TokenAuth implements AuthStrategy using a bearer token.
// Receiver name 't' matches the type TokenAuth.
type TokenAuth struct {
secret string
}
// Authenticate validates the token from the request header.
func (t *TokenAuth) Authenticate(req *http.Request) bool {
token := req.Header.Get("Authorization")
return token == "Bearer "+t.secret
}
// Handler handles HTTP requests using the injected auth strategy.
// The strategy is passed via the constructor, not a global variable.
func NewHandler(auth AuthStrategy) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if !auth.Authenticate(r) {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
fmt.Fprintln(w, "access granted")
})
}
If you pass a function where an interface is expected, the compiler checks the signature. If the types don't match, you get cannot use func literal as type AuthStrategy in argument. This happens when the function signature differs from the interface method. Go does not coerce types. The signature must align exactly.
Strategy is just a function. If you need an interface, you probably have two methods. If you have one method, use a function parameter.
Observer: Channels and callbacks
The Observer pattern notifies subscribers when a state changes. In Go, you have two tools: callbacks for synchronous updates and channels for asynchronous ones. Callbacks are simple. You store a slice of functions and call them in a loop. Channels add concurrency. Subscribers read from a channel in their own goroutines.
Channels are the idiomatic choice for observers in Go. They decouple the publisher from the subscriber. The publisher sends a message and moves on. The subscriber receives it whenever it is ready. This fits Go's concurrency model. You run the observer logic in a goroutine and feed it via a channel.
// EventSource emits events to subscribers via a channel.
// Channels handle the observer pattern with built-in concurrency support.
type EventSource struct {
// subscribers holds channels that want to receive events.
// Each subscriber gets its own channel to avoid blocking others.
subscribers []chan string
}
// Subscribe adds a new channel to receive events.
// The caller must buffer the channel or read in a goroutine to avoid blocking.
func (e *EventSource) Subscribe() chan string {
// Buffer size 1 prevents blocking on the first publish.
// Larger buffers absorb bursts but use more memory.
ch := make(chan string, 1)
e.subscribers = append(e.subscribers, ch)
return ch
}
// Publish sends a message to all subscribers.
// This runs in the caller's goroutine. Blocking subscribers will block the publisher.
func (e *EventSource) Publish(msg string) {
for _, ch := range e.subscribers {
ch <- msg
}
}
The danger with channels is blocking. If a subscriber stops reading, the send operation ch <- msg blocks. If the publisher blocks, the whole program can stall. You need a way to handle slow or dead subscribers. A common pattern is to use select with a default case. This makes the send non-blocking. If the channel is full, the message is dropped.
// PublishSafe sends messages without blocking the publisher.
// It drops messages if a subscriber's channel is full.
func (e *EventSource) PublishSafe(msg string) {
for _, ch := range e.subscribers {
select {
case ch <- msg:
// Message sent successfully.
default:
// Channel full. Drop the message to avoid blocking.
// In production, you might log this or increment a counter.
}
}
}
Goroutine leaks are the worst bug in concurrent Go. If a subscriber goroutine waits on a channel that never closes, it runs forever. You must provide a cancellation path. Pass a context.Context to the observer goroutine. Check ctx.Done() in the loop. When the context cancels, the goroutine exits.
// LogSink observes log messages and writes them to a destination.
// Using context allows the sink to stop observing when the server shuts down.
func LogSink(ctx context.Context, events <-chan string, dest io.Writer) {
// Loop until context is cancelled or channel is closed.
for {
select {
case <-ctx.Done():
return
case msg, ok := <-events:
if !ok {
return
}
fmt.Fprintln(dest, msg)
}
}
}
If you forget to close a channel and a goroutine ranges over it, the program hangs. The runtime detects this and panics with all goroutines are asleep - deadlock!. This error appears when every goroutine is blocked on a channel operation and no progress is possible. Always close channels when the producer is done.
Channels are the observer. If the observer blocks, the publisher dies. Buffer the channel or use select.
Iterator: Range and channels
The Iterator pattern provides a way to traverse a collection. Go replaces this pattern with the range keyword. You use range over slices, maps, and channels. The syntax is uniform. You get the index and value for slices. You get the key and value for maps. You get the value for channels.
range over a slice is fast and predictable. It yields elements in order. range over a map is randomized. This is intentional. Go randomizes map iteration to prevent code from relying on hash order. If you need ordered iteration over a map, collect the keys, sort them, and iterate the sorted keys.
// IterateSlice demonstrates the built-in iterator for slices.
// Go's range keyword handles the index and value automatically.
func IterateSlice(items []string) {
for i, v := range items {
// i is the index, v is a copy of the element.
// Modifying v does not modify the slice.
fmt.Printf("%d: %s\n", i, v)
}
}
// IterateMap demonstrates map iteration with randomized order.
// The order changes on every run. Do not rely on it.
func IterateMap(m map[string]int) {
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}
}
Channels act as lazy iterators. You can range over a channel. The loop blocks until a value arrives. It exits when the channel closes. This is how you build streaming iterators. The producer runs in a goroutine, sends values, and closes the channel. The consumer ranges over the channel and processes values as they arrive.
// GeneratePrimes yields prime numbers up to a limit via a channel.
// This acts as a custom iterator that computes values on demand.
func GeneratePrimes(limit int) <-chan int {
ch := make(chan int)
go func() {
defer close(ch) // Close channel to signal end of iteration.
for n := 2; n <= limit; n++ {
if isPrime(n) {
ch <- n
}
}
}()
return ch
}
// isPrime checks if a number is prime.
func isPrime(n int) bool {
if n < 2 {
return false
}
for i := 2; i*i <= n; i++ {
if n%i == 0 {
return false
}
}
return true
}
Loop variable capture used to be a trap. In Go versions before 1.22, the loop variable was reused across iterations. If you captured it in a closure, all closures saw the final value. Go 1.22 fixed this. The loop variable is now created per iteration. If you write code that captures the loop variable in a way that suggests the old bug, the compiler rejects the program with loop variable i captured by func literal. This error forces you to acknowledge the change. The code now works correctly, but the compiler warns you if you rely on ambiguous capture patterns.
Range is the iterator. Channels are lazy iterators. Always close the channel, or the range never ends.
Decision matrix
Use a function parameter when the behavior is a single operation with a clear signature. Use a single-method interface when you need to group the behavior with other methods or pass it through multiple layers. Use a buffered channel when observers run in separate goroutines and you want to decouple the publisher from the subscriber speed. Use a slice of callback functions when observers are synchronous and you don't need concurrency. Use range over a slice or map when you need to traverse a finite collection in memory. Use range over a channel when you need a lazy, streaming iterator that produces values over time. Use a custom iterator function returning a channel when you want to encapsulate the generation logic and handle cleanup automatically. Use sequential code when you don't need flexibility: hardcoding the logic is faster and easier to debug than injecting a strategy.