Design Patterns in Go: The Once-per-Lifetime Patterns That Actually Pay Off
Every Go developer has read Design Patterns (Gang of Four, 1994) and thought “this is C++-ism”. The refactoring.guru catalogue is the friendlier reference, but most of it is inheritance-based, and Go has no inheritance. So what is left? The answer is smaller than you expect, and that is the point.
This post walks the whole 23-pattern catalogue and marks which patterns Go handles natively (with the exact idiom) and which are better left to a library. The organizing rule is the thesis of the post:
If a pattern is about object identity / polymorphism, Go’s type system already does it with a function type, an interface, or a generic. If it is about a state machine / lifecycle, the pattern is a struct + a
switch. If it is about concurrent behavior, the pattern is a goroutine + a channel.
Apply that three-way split and most of the catalogue collapses into idioms you already know. By the end you will be able to recognize which of the 23 are native, which are “write the ten-line wrapper”, and which to outsource to a library — and write the ~20-line Go that is the real “Strategy + Decorator” without a single class hierarchy.
The 23, ranked by “Go does this natively”
The table is the whole classification. The “Native?” column is the verdict.
| Pattern (Gang of Four) | Go idiom | Native? |
|---|---|---|
| Singleton | package-level var + sync.Once | yes, trivially |
| Factory | constructor NewFoo(deps...) *Foo | yes, idiomatic |
| Builder | functional options func WithX(x T) Opt | yes, idiomatic |
| Strategy | function type type Price = func(Order) (float64, error) | yes, idiomatic |
| Observer | chan Event + a Subscribe() method | yes, goroutine-native |
| Command | type Command struct { fn func(ctx context.Context) } | yes |
| Adapter | just write the 10-line wrapper func | yes |
| Facade | a package that exposes 1 entry, hides 20 files | yes, idiomatic |
| Decorator | wrap type Handler func(Req) (Resp, error) | yes, idiomatic |
| Chain of Responsibility | for _, h := range handlers { ... } | yes, middleware is this |
| Proxy | wrap the real thing in struct { inner T } | yes |
| Memento | snapshot := copy(orig) (Go has no reference type) | mostly |
| State | type State struct { cur State; next func() } | yes |
| Mediator | a hub chan all parts talk to | yes, idiomatic |
| Prototype | clone := orig (value copy) — no deep-clone built-in | partly |
| Flyweight | a sync.Map of shared payloads + a key struct | yes |
| Visitor | type Visitor interface { Visit(*T) } | mostly |
| Composite | type Node interface { children() []Node } | yes |
| Iterator | func(f func(T) bool) (bool, error) (the Slice pattern) | yes, idiomatic |
| Template Method | not native — no inheritance; a function that takes a func | no |
| Interpreter | write a real parser (go/parse) — a pattern, not a type | no |
| Active Object | a goroutine + a chan Request | yes, it’s the goroutine |
| Bridge | type Bridge interface { Bridge() *Impl } + two hierarchies | mostly |
The one pattern that is always the right answer in Go: Active Object =
goroutine + channel. The Gang of Four wrote it in 1994, before goroutines
existed. Every time you would write an ActiveObject in C++, you write a
go func() { for req := range c { ... } } in Go. That is not a pattern; that
is a feature.
classDiagram
class FunctionType {
<>
Price = func(Order) (float64, error)
Handler = func(Req) (Resp, error)
}
class Closure {
<>
WithTax(base Price) Price
WithVAT(base Price) Price
}
class Hub {
<>
in chan Event
Subscribe() chan Event
}
class ActiveObject {
<>
cmds chan Command
Run(ctx)
}
FunctionType --|> Strategy : "is a"
Closure ..> FunctionType : "wraps"
FunctionType --|> Decorator : "is a"
Hub --|> Observer : "is a"
Hub --|> Mediator : "is a"
ActiveObject --|> Command : "is a"
ActiveObject --|> ActiveObjectPattern : "is a"
class ActiveObjectPattern {
<>
}
class Strategy {
<>
}
class Decorator {
<>
}
class Observer {
<>
}
class Mediator {
<>
}
class Command {
<>
}
The class diagram is the punchline: every “pattern” on the Go side is just a function value (Strategy/Decorator), a channel hub (Observer/ Mediator), or a goroutine (Command/Active Object). The GoF column is the 90% you do not write.
The idioms, in code
Singleton: sync.Once
The Go idiom for “one instance, global access” is a package-level variable
whose construction is guarded by sync.Once. The once guarantees the
expensive init runs exactly once no matter how many goroutines race to
Load().
| |
Note: the idiomatic Go Singleton is just a package variable. If you reach for a
Singletonstruct with anInstance()method and a lock, you are fighting the language.sync.Once+ a package var is the entire pattern.
Strategy + Decorator: the 20-line idiom
The real Go idiom for “Strategy” is a function type, and “Decorator” is a
closure that wraps one. No PriceStrategy interface, no *Price pointer, no
NewPriceStrategy() factory.
| |
Observer / Mediator: the channel hub
The Go idiom for “Observer” (and “Mediator”) is a single hub goroutine with an
inbound channel. Subscribing is just making a channel. No AddObserver /
RemoveObserver bookkeeping — the channel is the subscription.
| |
Command / Active Object: a goroutine
| |
Picking the idiom: a decision flow
Given a pattern you “need”, the flow below is the decision procedure. It encodes the thesis: identity/poly → function type, lifecycle → struct+switch, concurrency → goroutine+channel.
flowchart TD
A["I need 'a pattern'"] --> B{"Is it about object
identity / polymorphism?"}
B -- yes --> C{"Can one type serve all
callers?"}
C -- yes --> D["Use a function type
(Strategy / Decorator)"]
C -- no --> E["Use an interface
(Visitor / Adapter)"]
B -- no --> F{"Is it a state
machine / lifecycle?"}
F -- yes --> G["struct + switch
(State / Memento)"]
F -- no --> H{"Is it about concurrent
coordination?"}
H -- yes --> I["goroutine + channel
(Active Object / Mediator)"]
H -- no --> J{"Does it cross a
package boundary?"}
J -- yes --> K["Package = Facade"]
J -- no --> L{"More than ~30 lines
of type defs?"}
L -- yes --> M["You built a LIBRARY,
call it that, stop pattern-speak"]
L -- no --> N["It's an idiom, ship it"]
The 30-line rule, and when to outsource
| Signal | What it means | Do |
|---|---|---|
| ≤ 30 lines of type definitions | an idiom, not a framework | keep it inline, call it what it is |
| > 30 lines of type definitions | a class hierarchy in disguise | rename it a library, write a README, stop “pattern”-speak |
| needs reflection / interfaces with 5+ methods | you are fighting the type system | a small, well-tested library (e.g. a real parser) is cheaper to maintain |
| is really concurrency | it is not a “pattern” at all | goroutine + channel; the pattern name is a vestige |
Warning: the failure mode is not “I used the wrong pattern” — it is “I built a 400-line class hierarchy and called it a pattern so it feels smaller than it is.” In Go the 30-line rule is the line between idiom and framework. Under it, idiom. Over it, you have written a library — and that is fine, just say so and stop pretending it is a “pattern”. If your “pattern” needs a README, it is a library.
Takeaways
- Most of the 23-pattern catalogue is native in Go, because Go’s type system and concurrency model absorb the two categories GoF cared about: polymorphism (function types + interfaces) and concurrent behavior (goroutines + channels).
- The four idioms that matter: Singleton = package var +
sync.Once; Strategy/Decorator = function type + closure; Observer/Mediator = channel hub; Active Object/Command = goroutine + channel. - The 30-line rule: if a “pattern” needs more than ~30 lines of type definitions (not logic), you have built a library, not a pattern — call it that and stop the pattern-speak.
- Template Method and Interpreter are the two that are genuinely not native (no inheritance, and a real parser is a program, not a type) — outsource them to a small, well-tested library.
Next steps
- Audit your codebase for “pattern” classes: every
*Manager,*Factory,*Builderstruct with a 30+ line type surface is a candidate to collapse into a function type or a package. - Read your existing middleware (the
Chain of Responsibilityyou already ship) as the canonical Go “pattern” — it is the pattern, and it is ~10 lines. - If you outsource (a real parser, a state-machine library), make the boundary a package so the “pattern” stays a ten-line wrapper around a well-tested library, not a 400-line class in your domain code.