Skip to main content

Behavioral Design Patterns in Go

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 8 min read

Behavioral patterns deal with how objects communicate and distribute responsibility. Unlike structural patterns (which describe composition) or creational patterns (which describe instantiation), behavioral patterns describe the flow of control and data between objects at runtime.

Go’s approach differs from classical OOP implementations. There’s no inheritance, so “template method” works through interfaces and composition. Go’s implicit interfaces mean any type that satisfies the method signatures participates in the pattern — no explicit implements declaration needed. And channels provide a natural alternative to observer callbacks in concurrent code.

For creational patterns see Go creational design patterns and for structural patterns see Go structural design patterns.

Observer: Notify Dependents of State Changes

The Observer pattern lets objects subscribe to state changes in another object without tight coupling. The publisher doesn’t know about specific subscribers; it just notifies whoever is listening.

In Go, this is commonly implemented with a slice of callbacks or a channel-based event bus:

// EventBus is a simple publish-subscribe system
type EventBus struct {
    mu          sync.RWMutex
    subscribers map[string][]func(data any)
}

func NewEventBus() *EventBus {
    return &EventBus{subscribers: make(map[string][]func(data any))}
}

func (eb *EventBus) Subscribe(event string, fn func(data any)) {
    eb.mu.Lock()
    eb.subscribers[event] = append(eb.subscribers[event], fn)
    eb.mu.Unlock()
}

func (eb *EventBus) Publish(event string, data any) {
    eb.mu.RLock()
    subs := eb.subscribers[event]
    eb.mu.RUnlock()
    for _, fn := range subs {
        go fn(data)  // run concurrently — order not guaranteed
    }
}

For a concrete domain example — order processing notifying multiple downstream systems:

type OrderService struct {
    bus *EventBus
}

func (s *OrderService) PlaceOrder(order Order) error {
    // ... process payment, reserve inventory ...

    // Notify all interested parties — service doesn't know who they are
    s.bus.Publish("order.placed", order)
    return nil
}

// Independently registered — OrderService doesn't import these
func setupHandlers(bus *EventBus) {
    bus.Subscribe("order.placed", func(data any) {
        order := data.(Order)
        sendConfirmationEmail(order.CustomerEmail)
    })
    bus.Subscribe("order.placed", func(data any) {
        order := data.(Order)
        updateInventoryReservations(order.Items)
    })
    bus.Subscribe("order.placed", func(data any) {
        order := data.(Order)
        recordAuditLog(order)
    })
}

The EventBus decouples OrderService from email, inventory, and audit — those concerns can be added, removed, or changed without modifying the service. The sync.RWMutex allows concurrent reads (many subscribers notified simultaneously) while protecting modifications to the subscriber list.

For strictly sequential notification with error handling, replace go fn(data) with direct calls and return the first error.

Strategy: Swap Algorithms at Runtime

Strategy encapsulates a family of algorithms behind a common interface, making them interchangeable. The object using the algorithm doesn’t know which one it’s using — it only sees the interface.

A payment processing example:

// PaymentStrategy defines the interface all payment methods must satisfy
type PaymentStrategy interface {
    Charge(amount int, currency string) (string, error)  // returns transaction ID
    Refund(transactionID string, amount int) error
}

type StripeStrategy struct{ apiKey string }
type PayPalStrategy struct{ clientID, secret string }
type CryptoStrategy struct{ walletAddress string }

func (s *StripeStrategy) Charge(amount int, currency string) (string, error) {
    // Stripe-specific implementation
    return "stripe_txn_abc123", nil
}
// ... other methods

// Checkout uses whatever strategy is injected — doesn't care which one
type Checkout struct {
    payment PaymentStrategy
}

func NewCheckout(strategy PaymentStrategy) *Checkout {
    return &Checkout{payment: strategy}
}

func (c *Checkout) ProcessOrder(order Order) error {
    txnID, err := c.payment.Charge(order.TotalCents, order.Currency)
    if err != nil {
        return fmt.Errorf("payment failed: %w", err)
    }
    order.TransactionID = txnID
    return saveOrder(order)
}

Switching strategies at runtime:

checkout := NewCheckout(stripeStrategy)
// User switches to PayPal mid-checkout
checkout.payment = paypalStrategy

The strategy pattern is Go’s natural fit for any “pluggable” algorithm — sorting comparators, compression algorithms, authentication schemes, pricing rules. It’s simpler than subclassing because each strategy is just a value implementing an interface.

Command: Encapsulate Operations as Objects

Command wraps an operation and its parameters in an object. This enables queuing, logging, undo/redo, and retry without the caller knowing the operation’s internals.

A text editor undo stack:

type Command interface {
    Execute() error
    Undo() error
    Description() string
}

type History struct {
    commands []Command
}

func (h *History) Execute(cmd Command) error {
    if err := cmd.Execute(); err != nil {
        return err
    }
    h.commands = append(h.commands, cmd)
    return nil
}

func (h *History) Undo() error {
    if len(h.commands) == 0 {
        return fmt.Errorf("nothing to undo")
    }
    last := h.commands[len(h.commands)-1]
    h.commands = h.commands[:len(h.commands)-1]
    return last.Undo()
}

// Concrete command — insert text
type InsertCommand struct {
    doc    *Document
    pos    int
    text   string
}

func (c *InsertCommand) Execute() error {
    return c.doc.Insert(c.pos, c.text)
}

func (c *InsertCommand) Undo() error {
    return c.doc.Delete(c.pos, len(c.text))
}

func (c *InsertCommand) Description() string {
    return fmt.Sprintf("insert %q at %d", c.text, c.pos)
}

Command also works well for job queues — serialize commands to a queue, workers deserialize and execute them, failed commands can be retried with the same interface.

State: Change Behavior Based on Internal State

The State pattern models an object whose behavior changes depending on which state it’s in. Instead of large if/switch blocks scattered through methods, each state gets its own type that handles transitions.

A traffic light:

type TrafficLight struct {
    state LightState
}

type LightState interface {
    Next(light *TrafficLight)
    Signal() string
}

type Red struct{}
func (r *Red) Signal() string             { return "STOP" }
func (r *Red) Next(light *TrafficLight)   { light.state = &Green{} }

type Green struct{}
func (g *Green) Signal() string           { return "GO" }
func (g *Green) Next(light *TrafficLight) { light.state = &Yellow{} }

type Yellow struct{}
func (y *Yellow) Signal() string           { return "CAUTION" }
func (y *Yellow) Next(light *TrafficLight) { light.state = &Red{} }

func (t *TrafficLight) Advance() { t.state.Next(t) }
func (t *TrafficLight) Signal()  { fmt.Println(t.state.Signal()) }

light := &TrafficLight{state: &Red{}}
light.Signal()   // STOP
light.Advance()
light.Signal()   // GO

For more complex state machines — connection states, order lifecycle, document workflow — consider a table-driven approach where transitions are explicit:

type State string
type Event string

type StateMachine struct {
    current     State
    transitions map[State]map[Event]State
    handlers    map[State]func()
}

func (sm *StateMachine) Trigger(event Event) error {
    nextStates, ok := sm.transitions[sm.current]
    if !ok {
        return fmt.Errorf("no transitions from state %s", sm.current)
    }
    next, ok := nextStates[event]
    if !ok {
        return fmt.Errorf("event %s not valid in state %s", event, sm.current)
    }
    sm.current = next
    if h, ok := sm.handlers[sm.current]; ok {
        h()
    }
    return nil
}

The table-driven approach is easier to reason about and test than a network of inter-referencing state types, especially for state machines with many states and transitions.

Chain of Responsibility: Pass Requests Along a Handler Chain

Chain of Responsibility passes a request along a chain of handlers until one handles it. Each handler decides whether to process the request or pass it to the next handler. HTTP middleware is the most common Go example of this pattern.

type Middleware func(http.Handler) http.Handler

// Each middleware either handles the request or passes to next
func authMiddleware(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  // request handled — chain stops here
        }
        next.ServeHTTP(w, r)  // pass to next handler in chain
    })
}

func loggingMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        start := time.Now()
        next.ServeHTTP(w, r)
        slog.Info("request", slog.Duration("d", time.Since(start)))
    })
}

// Chain them — outermost runs first
handler := loggingMiddleware(authMiddleware(finalHandler))

For non-HTTP use cases — input validation pipelines, error recovery chains, approval workflows:

type Handler[T any] interface {
    Handle(req T) (T, error)
    SetNext(Handler[T])
}

// Each handler in the chain either transforms the request or passes it on
// A nil next means end of chain

Iterator: Traverse a Collection Without Exposing Its Structure

Go doesn’t have a built-in iterator protocol, but the pattern emerges naturally through channels or callback functions.

Channel-based iterator for lazy traversal:

// TreeNode represents a node in a binary tree
type TreeNode struct {
    Value int
    Left, Right *TreeNode
}

// InOrder returns a channel that yields values in sorted order
func (n *TreeNode) InOrder(ctx context.Context) <-chan int {
    ch := make(chan int)
    go func() {
        defer close(ch)
        var walk func(*TreeNode)
        walk = func(node *TreeNode) {
            if node == nil {
                return
            }
            walk(node.Left)
            select {
            case ch <- node.Value:
            case <-ctx.Done():
                return
            }
            walk(node.Right)
        }
        walk(n)
    }()
    return ch
}

// Consumer doesn't know about the tree structure
root := buildTree()
for v := range root.InOrder(ctx) {
    fmt.Println(v)
}

Go 1.23 introduced range-over-function, which provides a cleaner syntax for iterators without channels:

// Go 1.23+ iterator function signature
func (n *TreeNode) InOrder() func(yield func(int) bool) {
    return func(yield func(int) bool) {
        var walk func(*TreeNode) bool
        walk = func(node *TreeNode) bool {
            if node == nil { return true }
            return walk(node.Left) && yield(node.Value) && walk(node.Right)
        }
        walk(n)
    }
}

// Usage (Go 1.23+)
for v := range root.InOrder() {
    fmt.Println(v)
}

Choosing When to Apply These Patterns

Patterns solve specific problems. Before reaching for one, identify the problem:

  • Too many conditionals routing behavior based on type or state → State pattern
  • Same algorithm with swappable implementation → Strategy pattern
  • Need undo/redo or operation queuing → Command pattern
  • Multiple systems need to react to an event → Observer/EventBus
  • Request must pass through multiple processing steps → Chain of Responsibility
  • Need to traverse a data structure without exposing it → Iterator

Don’t apply patterns preemptively. A single if/else or switch doesn’t need a State machine. Two implementations of an interface aren’t a Strategy unless you actually swap them at runtime.

Summary

  • Go’s implicit interfaces make behavioral patterns lightweight — any type satisfying the interface participates without boilerplate
  • Observer is naturally concurrent in Go using channels or goroutines; use mutexes to protect the subscriber list
  • Strategy is just an interface with multiple implementations — the simplest and most common pattern in Go
  • Command enables undo, queuing, and retry by wrapping operations in values
  • State machines work best as table-driven transitions for complex cases, or as interface-per-state for simple ones
  • Chain of Responsibility is the foundation of Go HTTP middleware — every http.Handler wrapper is an instance of this pattern

Resources

Comments

👍 Was this article helpful?