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.
The 30-second version
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.
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.
Strategy: pass the behaviour in
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:
// 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)) → 3500Now play with it. Change quantities and swap strategies; Total never changes:
// the caller picks the behaviour: total := Total(cart, Regular)
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) }Observer: tell everyone who signed up
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 valueBuilding one yourself, in Go
Go has no built-in event emitter, but the core is tiny: a Struct holding a slice of callbacks.
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":
That has three consequences you'll meet in §04:
- Order is predictable: subscribers run in the order they subscribed (for EventEmitter and the DOM; our Go slice too).
- A slow subscriber slows everyone: the publisher and every later subscriber wait for it.
- A crashing subscriber can stop the rest: if listener 1 throws,
emitstops there and listener 2 never runs.
Observer in real programs: leaks and slow subscribers
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.
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 for | The cost |
|---|---|---|
| Wait until there's room | Every event matters (payments, audit log) | One slow subscriber stalls the publisher and everyone else |
| Drop the event for that subscriber | Live updates where the next one replaces the last (prices, cursors, progress bars) | That subscriber misses events; count them so you notice |
| Disconnect the subscriber | Many 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:
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)
}()
}
}Command: a request you can hold in your hand
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:
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:
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:
orderId: 42, attempt: 1 }
(Redis, SQS, a DB table)
runs the command
// 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 typeThis 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.
State: list the legal moves, reject the rest
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:
| From | Event | To |
|---|---|---|
| pending | pay | paid |
| pending | cancel | cancelled |
| paid | ship | shipped |
| paid | cancel | cancelled (and refund) |
| shipped | deliver | delivered |
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:
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:
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.
Chain of Responsibility: pass it along
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:
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", trueVariant: 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.
Iterator: one item at a time
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
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:
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 return6 }7 }8 }9}1011for v := range Countdown(3) {12 if v == 1 {13 break14 }15 fmt.Println(v)16}
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.- Go pushes. Your loop body is turned into the
yieldfunction and handed to the producer. The producer calls it once per item;breakmakesyieldreturnfalse. - JS pulls. A generator is paused until the loop asks for the next value with
.next().breakcalls 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.
Template Method and Visitor: patterns you mostly skip
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:
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:
| type ↓ · operation → | Area | Perimeter |
|---|---|---|
| Circle | func (c Circle) Area() in circle.go | func (c Circle) Perimeter() in circle.go |
| Rect | func (r Rect) Area() in rect.go | func (r Rect) Perimeter() in rect.go |
At system scale: buses, workflows, event sourcing — and tests
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.
topic: order.paid
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:
1db.save(order) // state = 'paid'2broker.publish('order.paid')
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:
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:
| Command | Event | |
|---|---|---|
| Grammar | Imperative: PayOrder | Past tense: OrderPaid |
| Meaning | A request — it can be rejected | A fact — it already happened |
| Who decides | The handler, using the state machine | Nobody; it's history |
| Delivered to | Exactly one handler | Any 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.
| Pattern | What to test | How |
|---|---|---|
| Strategy | Each strategy alone; the caller with a fake | Call 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 |
| Command | Do then Undo restores the original | For every command type: before := doc; c.Do(&doc); c.Undo(&doc), then if doc != before { t.Fatal(…) } |
| State | Every (state, event) pair | Loop over all states × all events; compare against the expected table (below) |
| Chain | Each handler alone, plus the order | Unit-test handlers in isolation; one test that a $30 refund never reaches the manager |
| Iterator | Early break | Break 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:
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.
Pitfalls gallery
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.
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)total := sub // there's one rule. When a second one
// arrives, pass a func(int) int (§02).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.
function openChat() {
socket.on("message", (m) => render(m)); // added again on every open
}function openChat() {
const onMessage = (m) => render(m);
socket.on("message", onMessage);
return () => socket.off("message", onMessage); // caller runs this on close
}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:
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
}
}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
}
}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.
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.
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.
An iterator that ignores yield's answer
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 falsereturn 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).
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.
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.
import { EventEmitter } from "node:events";
const e = new EventEmitter();
e.on("ping", () => console.log("A"));
console.log("before");
e.emit("ping");
console.log("after");editor.run(insert(0, "a"));
editor.run(insert(1, "b"));
editor.undo();
editor.run(insert(1, "c"));
editor.redo();
console.log(editor.text);const (
A = iota
B
_
D
)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)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); }function* gen() {
console.log("start");
yield 1;
}
const it = gen();
console.log("made");
it.next();Cheat sheet
type Pricer func(int) int); several → an interface. Sorting comparators are strategies.emit is synchronous: in order, and a slow or throwing listener affects the rest.subscribe returns its undo. Tie it to the subscriber's lifetime (useEffect cleanup, AbortSignal, defer unsubscribe()).iter.Seq[T], always check yield. JS: function*, [Symbol.iterator], try/finally for cleanup.| Pattern | Go idiom | JS idiom | Reach for it when… |
|---|---|---|---|
| Strategy | func type or small interface | pass a function | a second variant of an algorithm shows up |
| Observer | slice of funcs, or a channel per subscriber | EventEmitter, addEventListener | one event, several independent reactions |
| Command | struct with Do/Undo methods | object with do/undo, or a JSON job | you need undo, queues, retries or an audit log |
| State | iota constants + map[State]map[Event]State | Object.freeze + table + Object.hasOwn | a status field has rules about what may follow what |
| Chain of Responsibility | []Handler looped in order | array of functions | handlers or their order vary, or come from different teams |
| Iterator | iter.Seq[T] + range | generator + for…of | items are computed on demand, or your own type should be loopable |
| Template Method | pass the varying step as a func | pass the varying step as a function | (rarely — it's Strategy with inheritance) |
| Visitor | type switch switch v := x.(type) | switch (x.kind) | you add operations more often than types |