Skip to content
Software Atlas

Behavioral Patterns

How pieces of a program divide the work and talk to each other: Strategy, Observer, Command, State, Chain of Responsibility and Iterator — in idiomatic Go and JavaScript, with the production problems each one brings.

⏱ ~45 min read13 sections9 interactive widgetsGo · JavaScript
FoundationsPractitionerStaff-level
Foundations§01

The 30-second version

In one sentenceBehavioral patterns are named ways for pieces of code to split up work and talk to each other — who decides, who gets told, who does it later, and who is allowed to do what right now.

A design pattern is a solution shape that keeps coming back, with a name attached. The names matter more than you'd think: "let's make pricing a strategy" or "that's an observer leak" says in four words what would otherwise take a paragraph.

Most of these names come from a 1994 book by the Gang of Four. It groups patterns into three families. Creational patterns are about making objects (Creational), structural ones about wrapping and combining them (Structural), and behavioral ones — this page — about how they cooperate while the program runs.

Six behavioral patterns, six everyday pictures
🧭Strategy
“How should this step be done?”
Sat-nav route options: fastest, shortest, no tolls. Same trip, swappable rule.
cart→pricing rule ⇄
📰Observer
“Who needs to know?”
A newsletter: the writer sends once; every subscriber gets a copy. The writer never knows who reads it.
publisher⇉subscribers
🧾Command
“Can I keep this request for later?”
A restaurant order ticket: the order is written down, queued for the kitchen, and can be cancelled.
caller→ticket→doer
🚦State
“What is allowed right now?”
A traffic light: it's always in exactly one state, and only some changes are legal (never green → red without yellow).
pending→paid→shipped
☎️Chain of Responsibility
“Who can handle this?”
A support desk: the bot tries, then an agent, then an engineer, until someone takes the ticket.
bot→agent→manager
📖Iterator
“Give me the next one.”
Turning pages: you read one page at a time without caring how the book was bound.
loop⇄next item
Each pattern answers one question about how parts of a program cooperate. Keep the picture; the code follows from it.

The one idea underneath all six

Every pattern here does the same trick: it takes a piece of behaviour that's tangled into some code and pulls it out into its own value — a function, an object, a message. Once behaviour is a value, you can swap it (Strategy), hand it to many listeners (Observer), store it for later (Command), look it up in a table (State), line it up (Chain), or ask for it one item at a time (Iterator).

That's also why these patterns look smaller in Go and JavaScript than in the 1994 book. Both languages have first-class functions and closures, so "pull the behaviour out into a value" is often just "pass a function". Each section shows both: the classic shape with an Interface or Class, and the one-line version you'll actually write most days.

Foundations§02

Strategy: pass the behaviour in

In one sentenceStrategy = the code that does the job takes the how as an argument, so callers can plug in a different rule without editing it.

The problem it solves

Here is the version everyone writes first. Each new discount means editing total and adding a branch:

function total(items, discount) {
  const sub = items.reduce((sum, it) => sum + it.cents * it.qty, 0);
  if (discount === "ten") return Math.floor((sub * 90) / 100);
  if (discount === "bulk") return sub >= 3000 ? sub - 500 : sub;
  // …a new `if` for every marketing campaign, forever
  return sub;
}

With Strategy, total stops knowing about discounts at all. It asks for a function that turns a subtotal into a price, and calls it:

pricing.go
// Money is in cents (int) so there are no rounding surprises.
type Item struct {
    Name  string
    Cents int
    Qty   int
}

type Pricer func(subtotal int) int // ← the strategy is just a function type

func Regular(sub int) int       { return sub }
func TenPercentOff(sub int) int { return sub * 90 / 100 }

// A strategy with settings: a function that RETURNS a Pricer.
func BulkDiscount(threshold, off int) Pricer {
    return func(sub int) int { // this closure remembers threshold and off
        if sub >= threshold {
            return sub - off
        }
        return sub
    }
}

func Total(items []Item, price Pricer) int {
    sub := 0
    for _, it := range items {
        sub += it.Cents * it.Qty
    }
    return price(sub) // ← Total doesn't know or care which rule this is
}

// Total(cart, Regular)                  → 4000
// Total(cart, TenPercentOff)            → 3600
// Total(cart, BulkDiscount(3000, 500))  → 3500

Now play with it. Change quantities and swap strategies; Total never changes:

Strategy switcher
One cart, one Total function. Only the pricing strategy you pass in changes.
The cart (never changes shape)items
📕 book $15.002
🖊 pen $2.504
📓 notebook $6.000
subtotal: $40.00
// the caller picks the behaviour:
total := Total(cart, Regular)
Same cart, every strategytotal
Regular: No discount: the price is the subtotal.

You already use this pattern: sorting

The standard sort functions in both languages are Strategy. The sort algorithm is fixed; you pass the comparison rule:

import (
    "cmp"
    "slices"
)

// Go 1.21+: the comparator returns <0, 0 or >0
slices.SortFunc(users, func(a, b User) int {
    return cmp.Compare(a.Age, b.Age) // ← the strategy: "order by age"
})

// Older code uses sort.Slice, whose "less" func compares by index:
// sort.Slice(users, func(i, j int) bool { return users[i].Age < users[j].Age })

A function, or an interface?

The classic textbook Strategy is an Interface with one Method. That's still the right choice when the strategy has several methods or carries configuration you want to inspect:

type PricingStrategy interface {
    Price(subtotal int) int
    Name() string // e.g. for the receipt: "Summer sale −20%"
}

type Seasonal struct{ PercentOff int }

func (s Seasonal) Price(sub int) int { return sub * (100 - s.PercentOff) / 100 }
func (s Seasonal) Name() string      { return fmt.Sprintf("Seasonal −%d%%", s.PercentOff) }
Foundations§03

Observer: tell everyone who signed up

In one sentenceObserver = the thing that changes keeps a list of "call me when it happens" functions, and calls every one of them when it happens — without knowing who they are.

The problem it solves

When an order is paid, several things must happen: email a receipt, update the sales stats, notify the warehouse. The direct way is to call them all from the payment code:

function markPaid(order) {
  order.status = "paid";
  sendReceipt(order);    // payment code now depends on email…
  updateStats(order);    // …and on analytics…
  notifyWarehouse(order); // …and on the warehouse. A 4th feature = edit this again.
}

That's tight Coupling: the payment code has to import, and change for, every feature that cares about payments. With Observer, the payment code just announces "an order was paid", and each feature subscribes on its own. This style is often called publish/subscribe; the announcer is the publisher (or "subject"), the listeners are subscribers (or "observers").

Built in: EventEmitter and addEventListener

You've probably used Observer already. In Node it's EventEmitter; in the browser every DOM element is a publisher:

import { EventEmitter } from "node:events";

const orders = new EventEmitter();

const sendReceipt = (order) => console.log("email receipt for", order.id);
orders.on("paid", sendReceipt);                                        // subscribe
orders.on("paid", (order) => console.log("update stats for", order.id)); // another one

orders.emit("paid", { id: 42 });   // publish: calls BOTH listeners, in order
// email receipt for 42
// update stats for 42

orders.off("paid", sendReceipt);   // unsubscribe: needs the SAME function value

Building one yourself, in Go

Go has no built-in event emitter, but the core is tiny: a Struct holding a slice of callbacks.

publisher.go
type OrderPaid struct{ OrderID int }

type Publisher struct {
    subs []func(OrderPaid) // the list of "call me" functions
}

func (p *Publisher) Subscribe(fn func(OrderPaid)) {
    p.subs = append(p.subs, fn)
}

func (p *Publisher) Publish(e OrderPaid) {
    for _, fn := range p.subs {
        fn(e) // ← call each subscriber, one after another
    }
}

func main() {
    var orders Publisher
    orders.Subscribe(func(e OrderPaid) { fmt.Println("email receipt for", e.OrderID) })
    orders.Subscribe(func(e OrderPaid) { fmt.Println("update stats for", e.OrderID) })
    orders.Publish(OrderPaid{OrderID: 42})
}

Delivery is synchronous: everyone waits in line

The most important thing to know about the code above — and about EventEmitter and DOM events — is that Publish/emit calls each subscriber directly and waits for it to return before moving to the next. Nothing runs "in the background":

emit() calls every listener, in order, before it returns
callerconsole.log("before")emit("paid") …waiting…waiting…console.log("after")
listener 1sendReceipt()
listener 2updateStats()
Time flows left to right. While the listeners run, the caller is paused inside emit() (dashed). A slow listener makes the caller — and every listener after it — wait.

That has three consequences you'll meet in §04:

  1. Order is predictable: subscribers run in the order they subscribed (for EventEmitter and the DOM; our Go slice too).
  2. A slow subscriber slows everyone: the publisher and every later subscriber wait for it.
  3. A crashing subscriber can stop the rest: if listener 1 throws, emit stops there and listener 2 never runs.
Practitioner§04

Observer in real programs: leaks and slow subscribers

In one sentenceAn observer list is a list of promises you've made: "I'll call you". Forget to cross people off and you leak memory; let one slow listener hold the line and everyone waits.

The code in §03 works in a demo. In a long-running program — a server that's up for weeks, a single-page app the user keeps open all day — two problems appear. Try both in the simulation, then we'll fix them in code.

One publisher, many subscribers
Every subscriber has its own small queue (3 slots). Make one slow, or close the chat panel without unsubscribing, and watch what happens to everyone else.
Publisher
orders.Publish()
published: 0
next event: #1
idle
📧 email receipt
→···
handled 0
📊 stats
→···
handled 0
💬 chat panel
→···
handled 0
Chat panel:
Try this: press ▶, then tick “stats is slow”. With wait when full, the slow subscriber stalls the publisher — and the fast ones starve. Switch to drop when full.
Simplified: in “wait when full” mode the widget holds the event back from everyone. Real code that loops over subscribers would hand the event to the ones before the full subscriber, then get stuck — the ones after it still wait.

Problem 1: the forgotten unsubscribe

Each subscriber function is stored in the publisher's list. As long as it's in the list, the garbage collector can't free it — or anything the function's closure refers to (the chat panel, its messages, its DOM nodes). If the panel is closed but never unsubscribes, that memory is kept forever: a Memory leak.

It gets worse when the panel is reopened: a new listener subscribes while the old one is still there. Now every message is handled twice — the classic "why do I see each notification two times?" bug. Node even warns you when one event collects too many listeners:

MaxListenersExceededWarning: Possible EventEmitter memory leak detected.
11 tick listeners added to [EventEmitter]. MaxListeners is 10.

The fix: make unsubscribing impossible to forget. Return the unsubscribe function from subscribe, so whoever subscribes holds the means to undo it, and tie it to the subscriber's lifetime. (If you don't use React: the function you return from useEffect is React's "this component is going away" hook, and [] means "set this up once".)

useEffect(() => {
  const onPaid = (order) => setLatest(order);
  orders.on("paid", onPaid);
  return () => orders.off("paid", onPaid); // ← React calls this when the component goes away
}, []);

Problem 2: the slow subscriber

With synchronous delivery (§03), the publisher calls subscribers one after another. If updateStats takes 2 seconds, every emit takes 2 seconds, and the email receipt — next in line — waits too.

The obvious fix is to give each subscriber its own queue and let it work through that queue at its own pace. In Go that's exactly what a Buffered channel is. But a queue only moves the problem: what should the publisher do when one subscriber's queue is full? There are only three answers, and you must pick one on purpose:

When a subscriber's queue is full…Good forThe cost
Wait until there's roomEvery event matters (payments, audit log)One slow subscriber stalls the publisher and everyone else
Drop the event for that subscriberLive updates where the next one replaces the last (prices, cursors, progress bars)That subscriber misses events; count them so you notice
Disconnect the subscriberMany untrusted clients (e.g. WebSocket users on a slow phone)The client must reconnect and catch up

Here is an in-process hub (a "mini broker" living inside one Go program — not a separate server like the ones in §10) that uses one buffered channel per subscriber and the drop policy. It's safe to use from many goroutines because a mutex guards the subscriber set:

hub.go
type Event struct{ OrderID int }

type Hub struct {
    mu   sync.Mutex                // guards subs: one goroutine at a time
    subs map[chan Event]struct{}   // a set of subscriber channels
}

func NewHub() *Hub {
    return &Hub{subs: map[chan Event]struct{}{}}
}

// Subscribe returns TWO things: the channel to read from, and the function that undoes it.
func (h *Hub) Subscribe(buffer int) (<-chan Event, func()) {
    ch := make(chan Event, buffer)
    h.mu.Lock()
    h.subs[ch] = struct{}{}
    h.mu.Unlock()

    unsubscribe := func() {
        h.mu.Lock()
        defer h.mu.Unlock()
        if _, ok := h.subs[ch]; ok { // safe to call twice
            delete(h.subs, ch)
            close(ch) // "no more events will come on this channel"
        }
    }
    return ch, unsubscribe
}

// Publish never blocks: a full subscriber misses this event instead.
func (h *Hub) Publish(e Event) (dropped int) {
    h.mu.Lock()
    defer h.mu.Unlock()
    for ch := range h.subs {
        select {
        case ch <- e: // room in this subscriber's buffer
        default: //       full → don't wait, drop it for this one only
            dropped++
        }
    }
    return dropped
}

A subscriber runs in its own goroutine. It needs a way to be told "stop" — otherwise it would wait for events forever, which is exactly the leak we're trying to avoid. The standard stop signal in Go is a context.Context:

func runStats(ctx context.Context, hub *Hub) {
    events, unsubscribe := hub.Subscribe(64)
    defer unsubscribe() // runs when this function returns, whatever the reason

    for {
        select {
        case e := <-events:  // an event arrived
            updateStats(e)
        case <-ctx.Done():   // the owner said stop (shutdown, panel closed…)
            return           // → the deferred unsubscribe() removes us from the hub
        }
    }
}

// The owner: ctx, cancel := context.WithCancel(context.Background())
//            go runStats(ctx, hub)
//            … later: cancel() — runStats returns and unsubscribes itself.

Problem 3: one bad subscriber silences the others

In Node, if a listener throws, the exception flies out of emit() and later listeners don't run. In Go it's worse: a callback that panics crashes the entire program — every goroutine — unless something recovers it. Either way, a bug in the stats code can stop receipts from being sent.

If subscribers are independent features, isolate them:

func (p *Publisher) Publish(e OrderPaid) {
    for _, fn := range p.subs {
        func() {
            defer func() {
                if r := recover(); r != nil {
                    log.Printf("subscriber panicked: %v", r) // log it, carry on
                }
            }()
            fn(e)
        }()
    }
}
Practitioner§05

Command: a request you can hold in your hand

In one sentenceCommand = instead of doing something right away, you build a small object that describes it — and that object can be run now, later, twice, somewhere else, or backwards.

The shape

A command has two parts: the data it needs (where to insert, what text) and the code to run it. For undo, it also knows how to reverse itself. Here's an editor where every change is a command:

editor.go
type Doc struct{ Text string }

type Command interface {
    Do(d *Doc)
    Undo(d *Doc)
}

// Insert remembers where and what — enough to do AND undo.
type Insert struct {
    Pos  int
    Text string
}

func (c Insert) Do(d *Doc)   { d.Text = d.Text[:c.Pos] + c.Text + d.Text[c.Pos:] }
func (c Insert) Undo(d *Doc) { d.Text = d.Text[:c.Pos] + d.Text[c.Pos+len(c.Text):] }

// Delete must SAVE what it removed, or it can't put it back.
type Delete struct {
    Pos, N  int
    removed string
}

func (c *Delete) Do(d *Doc) {
    c.removed = d.Text[c.Pos : c.Pos+c.N] // ← remember before destroying
    d.Text = d.Text[:c.Pos] + d.Text[c.Pos+c.N:]
}
func (c *Delete) Undo(d *Doc) { d.Text = d.Text[:c.Pos] + c.removed + d.Text[c.Pos:] }

// Usage (the Editor that runs them comes next):
//   ed.Run(Insert{Pos: 0, Text: "Hello world"})
//   ed.Run(&Delete{Pos: 5, N: 6}) // & = pass a pointer, so Do can save `removed`

Undo and redo are just two stacks

The editor doesn't know what any command does. It only keeps two lists and moves commands between them:

type Editor struct {
    doc        Doc
    undo, redo []Command
}

func (e *Editor) Run(c Command) {
    c.Do(&e.doc)
    e.undo = append(e.undo, c)
    e.redo = nil // ← a new edit throws away the old "future"
}

func (e *Editor) Undo() {
    if len(e.undo) == 0 {
        return
    }
    c := e.undo[len(e.undo)-1]     // take the most recent command…
    e.undo = e.undo[:len(e.undo)-1]
    c.Undo(&e.doc)                 // …reverse it…
    e.redo = append(e.redo, c)     // …and keep it in case of redo
}
// Redo is the mirror image: pop from redo, Do, push onto undo.

A stack is a list where you only add and remove at the top: last in, first out. Undo always reverses the most recent change, which is exactly a stack. Try it — and do the experiment in the hint:

Undo / redo with command objects
Each button creates a command object that knows how to do itself and how to undo itself. The editor only keeps two stacks.
(empty document)
Undo stack (top = most recent)0
empty
Redo stack0
empty
Try: type “Hello”, type “ world”, Undo, then type “!”. Where did “ world” go?

Why does a new edit clear the redo stack? Because the commands on it were recorded against a version of the text that no longer exists. Redoing "insert ' world' at 5" after you've typed something else at position 5 would put it in the wrong place.

Commands that leave the building: queues and retries

Because a command is data, it doesn't have to run in the same place or at the same time it was created. Write it as JSON, put it in a Job queue, and a separate worker process can run it later:

A command that outlives the request
web handler
→
{ type: "SendReceipt",
orderId: 42, attempt: 1 }
→
job queue
(Redis, SQS, a DB table)
→
worker process
runs the command
→
✓ done
✗ failed → back in queue, attempt: 2
✗ 5th failure → dead-letter queue (a human looks)
Because the request is plain data, it can be stored, sent to another process, retried on failure, and parked for a human if it keeps failing.
// Pseudo-code: real queue libraries (BullMQ, …) differ slightly in names.
// Web request handler: don't send the email now (slow, can fail) — enqueue a command.
await queue.add({ type: "SendReceipt", orderId: 42 });
res.status(202).send("accepted"); // respond to the user immediately

// Worker process, possibly on another machine:
const handlers = {
  SendReceipt: async ({ orderId }) => mailer.sendReceipt(await db.orders.get(orderId)),
};
worker.process(async (job) => handlers[job.type](job.data)); // look up the code by type

This is how background jobs work in every web stack (BullMQ in Node, River or Asynq in Go, Sidekiq in Ruby…). The queue gives you retries for free: if the email provider is down, the command goes back in the queue and runs again later. After too many failures it lands in a Dead-letter queue instead of retrying forever.

Practitioner§06

State: list the legal moves, reject the rest

In one sentenceState = the thing is always in exactly one named state, and a single table lists which events may move it to which next state. Anything not in the table is refused.

The problem: a pile of booleans

Here's how order status usually starts:

const order = { isPaid: false, isShipped: false, isDelivered: false, isCancelled: false };

Four booleans allow 2 × 2 × 2 × 2 = 16 combinations, but only 5 correspond to real statuses. What does isShipped: true, isPaid: false mean? Or isDelivered: true, isCancelled: true? Every if in the codebase has to defend against nonsense, and sooner or later one doesn't.

A finite state machine replaces the booleans with one field that holds one of five names, plus a list of allowed moves:

FromEventTo
pendingpaypaid
pendingcancelcancelled
paidshipshipped
paidcancelcancelled (and refund)
shippeddeliverdelivered

Everything not in this table — shipping an unpaid order, paying twice, cancelling a delivered one — is an error. Click through it. Then flip the toggle to "let me try anyway" and press the illegal buttons:

Order state machine
The order is always in exactly one state. Only the arrows in the table are legal.
payshipdelivercancelcancelpendingpaidshippeddeliveredcancelled
Double border = final state. Highlighted arrows = the legal moves from where you are now.
ship disabled — can't ship an order that is pending: it hasn't been paid yet — you'd be shipping for free.
deliver disabled — can't deliver an order that is pending: it hasn't been paid or shipped.
new order → pending

The code: states as an enum, transitions as a table

Neither language has an enum keyword (an Enum is a type restricted to a fixed set of named values), so each has an idiom:

order.go
type OrderState int

const (
    Pending   OrderState = iota // 0
    Paid                        // 1
    Shipped                     // 2
    Delivered                   // 3
    Cancelled                   // 4
)

func (s OrderState) String() string { // makes fmt.Println print "paid", not 1
    return [...]string{"pending", "paid", "shipped", "delivered", "cancelled"}[s]
}

type OrderEvent string

const (
    Pay     OrderEvent = "pay"
    Ship    OrderEvent = "ship"
    Deliver OrderEvent = "deliver"
    Cancel  OrderEvent = "cancel"
)

// The whole business rule in one place.
var transitions = map[OrderState]map[OrderEvent]OrderState{
    Pending: {Pay: Paid, Cancel: Cancelled},
    Paid:    {Ship: Shipped, Cancel: Cancelled},
    Shipped: {Deliver: Delivered},
    // Delivered and Cancelled: no entries → final states
}

type Order struct {
    ID    int
    State OrderState
}

func (o *Order) Fire(e OrderEvent) error {
    next, ok := transitions[o.State][e] // ← ok is false if the move isn't listed
    if !ok {
        return fmt.Errorf("can't %s an order that is %s", e, o.State)
    }
    o.State = next
    return nil
}

Table or switch?

You can write the same machine as a switch:

func (o *Order) Fire(e OrderEvent) error {
    switch {
    case o.State == Pending && e == Pay:
        o.State = Paid
    case o.State == Pending && e == Cancel, o.State == Paid && e == Cancel:
        o.State = Cancelled
    // …one case per arrow
    default:
        return fmt.Errorf("can't %s an order that is %s", e, o.State)
    }
    return nil
}

Both are fine for five states. The table wins as the machine grows: it's data, so you can print it, draw the diagram from it, test every pair of state and event in a loop (§10), or load it from config. The switch wins when each transition needs very different code, like charging a card on pay and calling the courier API on ship.

Practitioner§07

Chain of Responsibility: pass it along

In one sentenceChain of Responsibility = put handlers in a line. Each one either deals with the request or hands it to the next. The sender only talks to the first.
A ticket walks the chain
Each handler either handles the ticket or passes it to the next one. The sender only knows the first handler.
🤖 FAQ bot
handles: password resets
→
🧑 support agent
handles: refunds up to $100
→
🛠 engineer
handles: bugs
→
👔 manager
handles: any refund
→
end of chain

Two things to notice in the widget. First, order matters: the agent comes before the manager, so a $30 refund never bothers the manager. Second, with no catch-all at the end, an unexpected ticket ("pony") falls off the end silently. Always decide what happens when nobody handles it.

The code: a list of functions

The book version links handler objects together, each holding a pointer to the next. In Go and JS, a list of functions does the same job with less code:

support.go
type Ticket struct {
    Kind   string
    Amount int
}

// A handler returns ok=true if it took care of the ticket.
type Handler func(t Ticket) (reply string, ok bool)

func Chain(handlers ...Handler) Handler {
    return func(t Ticket) (string, bool) {
        for _, h := range handlers {
            if reply, ok := h(t); ok {
                return reply, true // first one that can handle it wins
            }
        }
        return "", false // nobody did — the caller must decide what that means
    }
}

func Agent(t Ticket) (string, bool) {
    if t.Kind == "refund" && t.Amount <= 100 {
        return "agent: refunded", true
    }
    return "", false // not mine → the chain moves on
}

// support := Chain(Bot, Agent, Engineer, Manager)
// support(Ticket{Kind: "refund", Amount: 900}) → "manager: approved", true

Variant: run every check until one fails — validation pipelines

Sometimes you don't want the first handler that matches; you want each one to check the request and stop at the first problem. Form and API validation work this way:

type SignUp struct{ Email, Password string }
type Rule func(SignUp) error

func Validate(u SignUp, rules ...Rule) error {
    for _, rule := range rules {
        if err := rule(u); err != nil {
            return err // stop at the first failure
        }
    }
    return nil
}

// Validate(form, emailHasAt, passwordLongEnough, notOnBlocklist)

Order the rules cheap first, expensive last: check the email's format before you ask the database whether it's already taken. And if users should see all problems at once, don't stop early — collect every error (in Go, errors.Join(errs...), Go 1.20+).

Variant: each handler decides whether to call next

In the most flexible form, each handler receives the request and a next function. It can do work before calling next, after it, or not call it at all (to block the request). That is exactly how HTTP middleware works — logging, auth, rate limits wrapped around your handler. It's covered properly in Structural Patterns, because each layer wraps the next one.

Practitioner§08

Iterator: one item at a time

In one sentenceIterator = a standard way to say "give me the next item" so a for loop can walk over anything — a tree, a database cursor, an infinite sequence — without knowing how it's stored, and without building the whole list first.

for x of array (JS) and for _, x := range slice (Go) already iterate. The pattern matters when your own type should work in that loop, or when the items are computed on demand: pages of API results, lines of a 10 GB file, rows from a database. Both languages now have a built-in way to write an Iterator with yield.

The code

countdown.go — Go 1.23+
import "iter"

// iter.Seq[int] is just: func(yield func(int) bool)
func Countdown(n int) iter.Seq[int] {
    return func(yield func(int) bool) {
        for i := n; i > 0; i-- {
            if !yield(i) { // hand i to the loop body; false = "the loop wants to stop"
                return
            }
        }
    }
}

for v := range Countdown(3) {
    fmt.Println(v) // 3, 2, 1
}

They look almost the same, but they work in opposite directions. Step through both:

Who calls whom in a loop over an iterator
Same countdown, two languages. Watch where control is at each step.
Code
1func Countdown(n int) iter.Seq[int] {
2 return func(yield func(int) bool) {
3 for i := n; i > 0; i-- {
4 if !yield(i) {
5 return
6 }
7 }
8 }
9}
10
11for v := range Countdown(3) {
12 if v == 1 {
13 break
14 }
15 fmt.Println(v)
16}
producer: Countdown
not started
consumer: your loop body
waiting
printed
nothing yet
range calls Countdown(3), which just returns a function. Go then calls that function, passing it yield — which is your loop body, wrapped up as a function.
1 / 7
  • Go pushes. Your loop body is turned into the yield function and handed to the producer. The producer calls it once per item; break makes yield return false.
  • JS pulls. A generator is paused until the loop asks for the next value with .next(). break calls the generator's .return() to shut it down.

Why bother: laziness

An iterator produces each item only when the loop asks. That lets you work with sequences that would be too big — or infinite — as a list:

// All pages of an API, fetched only as the loop needs them.
func AllUsers(c *Client) iter.Seq2[User, error] {
    return func(yield func(User, error) bool) {
        for page := 1; ; page++ {
            users, err := c.ListUsers(page)
            if err != nil {
                yield(User{}, err) // report the error; we stop either way
                return
            }
            if len(users) == 0 {
                return // no more pages
            }
            for _, u := range users {
                if !yield(u, nil) {
                    return // caller found what it wanted — don't fetch more pages
                }
            }
        }
    }
}

If the loop breaks after finding the user on page 2, pages 3, 4, 5… are never fetched. The function that uses the data decides how much work happens — the essence of the pattern.

Practitioner§09

Template Method and Visitor: patterns you mostly skip

In one sentenceSome classic patterns lean on features Go and JS handle differently. Template Method works around "can't pass a function"; Visitor gives you per-type code with the compiler checking that no type is forgotten. Go and JS usually trade both for something much shorter.

Template Method: a recipe with a blank in it

You have an algorithm whose overall shape is fixed, but one step varies. Template Method writes the skeleton in a base Class and leaves the varying step for subclasses to fill in through Inheritance:

A fixed recipe with one blank to fill in
1. split text into lines
2. for each non-blank line:
3. parseLine(line) ← the blank
4. collect the results into a list
⇐ fill with
parseCSV
parseJSONLine
parseLogLine
The skeleton (grey) is written once. Only the highlighted step varies. Template Method fills the blank by subclassing; in Go and modern JS you usually just pass a function.
class Importer {
  run(text) {                        // the fixed skeleton
    return text
      .split("\n")
      .filter((line) => line.trim() !== "")
      .map((line) => this.parseLine(line)); // ← the blank, filled by a subclass
  }
}

class CsvImporter extends Importer {
  parseLine(line) {
    const [name, age] = line.split(",");
    return { name, age: Number(age) };
  }
}

new CsvImporter().run("ana,31\nbo,25");

Look familiar? The "just pass the function" version is Strategy (§02). Template Method and Strategy solve the same problem; one uses inheritance, the other Composition. The function version is easier to test (no class to build), and you can't accidentally override a step you shouldn't have.

Visitor: do something different for each kind of thing

Say you have a few shape types and want to compute area, then perimeter, then draw them — new operations keep coming. The book's Visitor pattern adds an accept(visitor) method to every type, and each operation becomes a visitor class with one method per type (visitCircle, visitRect). It's a lot of ceremony, but it buys one real thing: when you add a new shape, the compiler flags every visitor that's missing a visitTriangle method. Nothing can be forgotten.

Go and JS usually skip the ceremony. Go has a Type switch; in JS you put a kind field on each object and switch on it:

// A small "marker" interface: only our shape types have isShape(),
// so Area can't be called with a random string or int.
type Shape interface{ isShape() }

type Circle struct{ R float64 }
type Rect struct{ W, H float64 }

func (Circle) isShape() {}
func (Rect) isShape()   {}

func Area(s Shape) float64 {
    switch v := s.(type) {    // ← which concrete type is inside s?
    case Circle:
        return math.Pi * v.R * v.R // v is a Circle here
    case Rect:
        return v.W * v.H           // v is a Rect here
    default:
        panic(fmt.Sprintf("Area: unhandled shape %T", s)) // a forgotten case shows up HERE, at runtime
    }
}
// A new operation (Perimeter) = one new function. No changes to Circle or Rect.

What you give up: add a Triangle and forget to update Area, and Go still compiles. The mistake only shows up when a triangle reaches the default branch and panics. Two cheap guards: always make default panic or throw loudly (never silently return 0), and write a test that calls every operation on one value of every type. (TypeScript users can get the compile-time check back with an "exhaustive switch".)

The deeper trade-off is about which direction your code grows. Try it:

Where does the code for each (type, operation) pair live?
Rows are shape types, columns are operations. Each cell is one piece of code you must write. Add a row or a column and see which files you touch.
type ↓ · operation →AreaPerimeter
Circlefunc (c Circle) Area()
in circle.go
func (c Circle) Perimeter()
in circle.go
Rectfunc (r Rect) Area()
in rect.go
func (r Rect) Perimeter()
in rect.go
Press “add a shape”, then flip the toggle. Then try “add an operation”. The cost moves from rows to columns.
Staff-level§10

At system scale: buses, workflows, event sourcing — and tests

In one sentenceThe same three ideas — notify, allowed transitions, requests as data — reappear at the scale of whole systems. The patterns don't change; the failure modes do.

Observer → message bus

Inside one program, subscribers are functions. Across services, they're other programs, and the subscriber list lives in a message broker (Kafka, RabbitMQ, NATS, SNS/SQS, Google Pub/Sub). The order service publishes order.paid; the email and analytics services subscribe without the order service knowing they exist.

Same pattern, two scales
In one process
orders
⇉
sendReceipt()
updateStats()
fast · simple · lost on crash · one program
Across services
order service
→
broker
topic: order.paid
⇉
email service
analytics
durable · retries · duplicates possible · teams deploy independently
Left: subscribers are functions in the same program — if it crashes, queued events are gone. Right: a broker stores events durably and delivers them to other services, at least once.

Every problem from §04 is still there, with new names:

  • Slow subscriber → consumer lag. Brokers store events, so a slow consumer falls behind instead of blocking the publisher — the "per-subscriber buffer" from §04, but on disk and huge. You monitor the lag.
  • Delivery becomes At-least-once delivery: after a crash or timeout, the broker re-delivers. Every consumer must be Idempotent — the same rule as queued commands in §05.
  • Ordering is usually only guaranteed per key. Kafka keeps events in order within a partition (one ordered slice of a topic, chosen by the message's key), so you send all events for order 42 with key 42, and they stay in order relative to each other — but not relative to order 43.

The dual-write trap

db.save(order); broker.publish("order.paid") — what if the program crashes between the two lines? The order is paid but nobody is told. Publish first and crash before saving, and everyone is told about a payment that didn't happen. The standard fix is the outbox pattern: in the same database Database transaction that updates the order, insert the event into an outbox table; a separate "relay" process reads the outbox and publishes. Either both rows are saved or neither is. Crash each version and compare:

Crash between two writes
Run the payment code, or crash the server right after line 1. Compare the two naive orders with the outbox.
1db.save(order) // state = 'paid'
2broker.publish('order.paid')
orders table
pending
outbox table
(no outbox)
subscribers saw
nothing yet
Try “crash after line 1” in each mode.

State → persisted workflows and sagas

In a real system the order's state lives in a database row, and several servers may try to move it at the same moment (two "pay" clicks, a webhook retry). The transition table from §06 still decides what's legal — but the check and the update must happen atomically, or both requests can see pending and both charge the card. Let the database do the check:

-- Only succeeds if the order is still pending. 0 rows updated = someone else got there first.
UPDATE orders SET state = 'paid', paid_at = now()
WHERE id = $1 AND state = 'pending';

The application reads "rows affected": 1 means the transition happened; 0 means it was illegal or lost the race, and it must not charge the card. The WHERE state = 'pending' clause is the transition table's row, enforced by the database.

A business process that spans several services — reserve stock, charge the card, book a courier — can't use one database transaction. A Saga models it as a state machine where each transition is a command to another service, and each step has a compensating action to undo it if a later step fails:

A saga is a state machine whose transitions are commands
1. reserve stock ✓
→
2. charge card ✓
→
3. book courier ✗
↩
refund card
←
release stock
compensations run in reverse order
Step 3 failed, so the saga walks backwards running each completed step's compensation: refund, then release. Each arrow is a command sent to another service; the saga's current state is saved in a database row.

So a saga is State + Command working together: the state machine says what happens next, commands do it, and compensations are the "undo" from §05 — at the scale of money and parcels. Workflow engines (Temporal, AWS Step Functions) exist to store that state durably and resume it after crashes.

Command → event sourcing

At system scale, keep two words apart:

CommandEvent
GrammarImperative: PayOrderPast tense: OrderPaid
MeaningA request — it can be rejectedA fact — it already happened
Who decidesThe handler, using the state machineNobody; it's history
Delivered toExactly one handlerAny number of subscribers (Observer)

The flow ties every pattern on this page together: a command arrives → the handler checks the state machine → if legal, it records an event → observers react. Event sourcing takes the last step literally: it stores the list of events as the source of truth and rebuilds the current state by replaying them through the state machine. (Separating the write side that handles commands from the read side that answers queries is called CQRS.)

Testing each pattern

Each pattern makes one kind of test easy. That's often the best argument for using it.

PatternWhat to testHow
StrategyEach strategy alone; the caller with a fakeCall TenPercentOff(1000) directly. Test Total with func(int) int { return 7 } — a Test double in one line
Observer"When X happens, subscribers are told"Subscribe a recording function that appends to a slice; publish; check the slice. Test that unsubscribe really stops delivery
CommandDo then Undo restores the originalFor every command type: before := doc; c.Do(&doc); c.Undo(&doc), then if doc != before { t.Fatal(…) }
StateEvery (state, event) pairLoop over all states × all events; compare against the expected table (below)
ChainEach handler alone, plus the orderUnit-test handlers in isolation; one test that a $30 refund never reaches the manager
IteratorEarly breakBreak after the first item and check no extra work happened (no panic, no extra page fetched)

The state machine test is worth showing, because it checks the illegal moves too — the ones bugs hide in:

order_test.go
func TestEveryTransition(t *testing.T) {
    states := []OrderState{Pending, Paid, Shipped, Delivered, Cancelled}
    events := []OrderEvent{Pay, Ship, Deliver, Cancel}
    legal := map[string]OrderState{ // "from/event" → expected next state
        "pending/pay": Paid, "pending/cancel": Cancelled,
        "paid/ship": Shipped, "paid/cancel": Cancelled,
        "shipped/deliver": Delivered,
    }
    for _, s := range states {
        for _, e := range events {
            o := &Order{State: s}
            err := o.Fire(e)
            want, ok := legal[fmt.Sprintf("%s/%s", s, e)]
            switch {
            case ok && (err != nil || o.State != want):
                t.Errorf("%s --%s--> got %s, err %v; want %s", s, e, o.State, err, want)
            case !ok && (err == nil || o.State != s):
                t.Errorf("%s --%s--> should be rejected and leave the state alone", s, e)
            }
        }
    }
}

All 20 combinations are checked — 5 legal, 15 illegal. Add a state or an event and the test immediately covers the new pairs.

Practitioner§11

Pitfalls gallery

#1

Using the pattern before the problem exists

A PricingStrategy interface, a PricingStrategyFactory, and exactly one implementation. Every reader has to jump through three files to find out that the price is the subtotal. A pattern is a tool for a change you're actually making — a second strategy, a third subscriber. Until then, write the plain function; Refactoring it into a pattern takes ten minutes when the second case shows up.

✕ Don't
type PricingStrategy interface{ Price(int) int }
type DefaultPricing struct{}
func (DefaultPricing) Price(s int) int { return s }
func NewPricing() PricingStrategy { return DefaultPricing{} }

total := NewPricing().Price(sub)
✓ Do
total := sub // there's one rule. When a second one
             // arrives, pass a func(int) int (§02).
#2

Subscribing without a way to unsubscribe

Every on, addEventListener or Subscribe call that runs more than once — per request, per component mount, per panel open — must be paired with its undo, or listeners pile up (§04). Make subscribe return the unsubscribe function so the pairing is obvious at the call site.

✕ Don't
function openChat() {
  socket.on("message", (m) => render(m)); // added again on every open
}
✓ Do
function openChat() {
  const onMessage = (m) => render(m);
  socket.on("message", onMessage);
  return () => socket.off("message", onMessage); // caller runs this on close
}
#3

Changing the subscriber list while publishing

A listener that subscribes or unsubscribes during emit (say, a "run once" listener removing itself) mutates the list the publisher is looping over. Depending on the implementation, a subscriber can be skipped or called twice. Node's EventEmitter copies the list before looping, which is why once() is safe there.

In Go it's worse. Take the §03 publisher and add a mutex so many goroutines can use it. If Publish holds the lock while calling subscribers, and a subscriber calls Subscribe (which needs the same lock), it waits for itself forever — a Deadlock:

✕ Don't
func (p *Publisher) Publish(e OrderPaid) {
    p.mu.Lock()
    defer p.mu.Unlock()
    for _, fn := range p.subs {
        fn(e) // fn calls p.Subscribe → Lock() again → stuck
    }
}
✓ Do
func (p *Publisher) Publish(e OrderPaid) {
    p.mu.Lock()
    subs := slices.Clone(p.subs) // copy under the lock…
    p.mu.Unlock()
    for _, fn := range subs {
        fn(e) // …call with the lock released
    }
}
#4

Booleans instead of a state field

isPaid, isShipped, isCancelled → 8 combinations, most of them nonsense, and every if must defend against them (§06). One state field plus a transition table makes impossible states unrepresentable. If you find yourself writing if (order.isShipped && !order.isPaid) "just in case", you've found one.

#5

An undo that didn't save what it destroyed

Delete{Pos: 3, N: 5} can't put back text it never recorded, and Uppercase can't know which letters were lowercase before. A command must capture everything its undo needs at the moment it runs (§05). In Go, that also means a pointer receiver, or the saved data goes into a copy and vanishes.

#6

A chain with no end

If no handler matches, the request quietly falls off the end — no reply, no error, no log line (the "pony" ticket in §07). Always end a chain with a catch-all that does something visible: return a 404, log a warning, put the ticket in a "needs a human" queue.

#7

An iterator that ignores yield's answer

✕ Don't
return func(yield func(int) bool) {
    for i := n; i > 0; i-- {
        yield(i) // loop body did `break`? we keep going…
    }
}
// panic: range function continued iteration
// after function for loop body returned false
✓ Do
return func(yield func(int) bool) {
    for i := n; i > 0; i-- {
        if !yield(i) {
            return // the loop said stop — stop
        }
    }
}

In JS the equivalent mistake is a generator holding a resource without try … finally: the loop breaks, the generator is closed, and the file or cursor is never released (§08).

#8

Passing a method as a JS strategy or listener and losing `this`

class Stats {
  count = 0;
  record() { this.count++; }
}
const stats = new Stats();
orders.on("paid", stats.record);              // ✗ `this` is the emitter, not stats → count never changes
orders.on("paid", () => stats.record());      // ✓ arrow keeps `stats` as `this`
orders.on("paid", stats.record.bind(stats));  // ✓ or bind it (keep a reference to remove it later)

When a method is passed as a bare function value, it forgets which object it belongs to. Go doesn't have this problem: stats.Record as a value (a "method value") remembers stats.

Practitioner§12

Check yourself

Predict the answer before you click. Q1 and Q5 are covered in §03–§04, Q2 in §05, Q3 and Q4 in §06, Q6 in §08.

QUESTION 1 / 6
In what order does this print?
import { EventEmitter } from "node:events";
const e = new EventEmitter();
e.on("ping", () => console.log("A"));
console.log("before");
e.emit("ping");
console.log("after");
QUESTION 2 / 6
Undo, then a new edit, then redo
editor.run(insert(0, "a"));
editor.run(insert(1, "b"));
editor.undo();
editor.run(insert(1, "c"));
editor.redo();
console.log(editor.text);
QUESTION 3 / 6
What's the value of D?
const (
    A = iota
    B
    _
    D
)
QUESTION 4 / 6
A shipped order gets a cancel event
var transitions = map[OrderState]map[OrderEvent]OrderState{
    Pending: {Pay: Paid, Cancel: Cancelled},
    Paid:    {Ship: Shipped, Cancel: Cancelled},
    Shipped: {Deliver: Delivered},
}
o := &Order{State: Shipped}
err := o.Fire(Cancel)
QUESTION 5 / 6
One listener throws
const e = new EventEmitter();
e.on("x", () => { throw new Error("boom"); });
e.on("x", () => console.log("second listener"));
try { e.emit("x"); } catch (err) { console.log("caught", err.message); }
QUESTION 6 / 6
When does “start” print?
function* gen() {
  console.log("start");
  yield 1;
}
const it = gen();
console.log("made");
it.next();
Answered 0 / 6
Foundations§13

Cheat sheet

🧭
StrategyPass the how in. One operation → a function type (type Pricer func(int) int); several → an interface. Sorting comparators are strategies.
📰
ObserverPublisher keeps a list of callbacks. emit is synchronous: in order, and a slow or throwing listener affects the rest.
🧹
Unsubscribesubscribe returns its undo. Tie it to the subscriber's lifetime (useEffect cleanup, AbortSignal, defer unsubscribe()).
🐢
Slow subscribersPer-subscriber buffer + a chosen policy when full: wait, drop (and count), or disconnect.
🧾
CommandA request as data. Two stacks give undo/redo; a queue gives later + retries — so make it idempotent.
🚦
StateOne state field, one transition table, an error for everything else. Test every (state, event) pair.
☎️
ChainHandlers in a line; first one that can handle it wins. Always end with a catch-all.
📖
IteratorGo 1.23+: iter.Seq[T], always check yield. JS: function*, [Symbol.iterator], try/finally for cleanup.
PatternGo idiomJS idiomReach for it when…
Strategyfunc type or small interfacepass a functiona second variant of an algorithm shows up
Observerslice of funcs, or a channel per subscriberEventEmitter, addEventListenerone event, several independent reactions
Commandstruct with Do/Undo methodsobject with do/undo, or a JSON jobyou need undo, queues, retries or an audit log
Stateiota constants + map[State]map[Event]StateObject.freeze + table + Object.hasOwna status field has rules about what may follow what
Chain of Responsibility[]Handler looped in orderarray of functionshandlers or their order vary, or come from different teams
Iteratoriter.Seq[T] + rangegenerator + for…ofitems are computed on demand, or your own type should be loopable
Template Methodpass the varying step as a funcpass the varying step as a function(rarely — it's Strategy with inheritance)
Visitortype switch switch v := x.(type)switch (x.kind)you add operations more often than types
Finished reading?Mark it complete to track your progress on a roadmap. Saved in this browser only.
Up next in Design PatternsDependency Injection
Read →