Step 10 of 24
Design patterns
Recognise and apply the patterns that matter in modern Go and TypeScript — strategy, adapter, decorator, observer, factory, functional options — and know when to skip them.
A design pattern is a named, reusable solution to a problem that keeps recurring in software design. The value of a pattern is mostly the name: saying "wrap the client in a retrying decorator" communicates a whole design in five words. The 1994 Design Patterns book catalogued 23 of them for C++ and Smalltalk; many are workarounds for features those languages lacked, and in Go or TypeScript they collapse into a function or an interface. This lesson covers the handful you'll actually use, in idiomatic modern form, and how to tell when a pattern is helping versus adding ceremony.
Strategy: swap an algorithm
Problem: a piece of behaviour varies — pricing rules, sorting order, retry policy — and you don't want an if/else chain choosing between them inside the code that uses it.
Solution: put the varying behaviour behind an interface (or a function type), and pass in the one you want.
type Discount interface {
Apply(subtotal Money) Money
}
type NoDiscount struct{}
func (NoDiscount) Apply(s Money) Money { return s }
type Percentage struct{ Percent int }
func (p Percentage) Apply(s Money) Money { return s - s*Money(p.Percent)/100 }
type FlatOff struct{ Amount Money }
func (f FlatOff) Apply(s Money) Money { return max(s-f.Amount, 0) }
func Checkout(items []Item, d Discount) Money {
return d.Apply(Subtotal(items))
}When the strategy is a single operation, a function type is simpler than an interface, and very common in Go and TypeScript:
type Discount func(subtotal Money) MoneyYou've used this pattern already: slices.SortFunc(users, cmpByAge) takes a comparison strategy; http.HandlerFunc is a strategy for handling a request. Strategy is the concrete tool behind the open–closed principle from the SOLID lesson.
Adapter: make an interface fit
Problem: you have code that expects interface A, and a type — often from a third-party SDK — that does the job but exposes interface B.
Solution: write a small type that implements A by translating calls to B.
// What our code depends on (declared by us)
type SMSSender interface {
Send(ctx context.Context, phone, text string) error
}
// Adapter for a vendor SDK with a different shape
type TwilioAdapter struct {
client *twilio.RestClient
from string
}
func (a TwilioAdapter) Send(ctx context.Context, phone, text string) error {
params := &openapi.CreateMessageParams{}
params.SetTo(phone)
params.SetFrom(a.from)
params.SetBody(text)
_, err := a.client.Api.CreateMessage(params)
return err
}Adapters are how you keep vendor types out of your business logic: the rest of the code sees only SMSSender, and switching provider means writing a new adapter, not editing every caller. In hexagonal architecture, these are literally called adapters.
Decorator: add behaviour around a call
Problem: you want to add logging, metrics, caching, retries or authorization to something without changing it — and be able to combine those additions freely.
Solution: a type that implements the same interface, holds the original, and does extra work before or after delegating.
type RetryingSender struct {
next SMSSender
attempts int
}
func (r RetryingSender) Send(ctx context.Context, phone, text string) error {
var err error
for i := range r.attempts {
if err = r.next.Send(ctx, phone, text); err == nil {
return nil
}
if i == r.attempts-1 {
break // no point waiting after the last attempt
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(1<<i) * 100 * time.Millisecond):
}
}
return fmt.Errorf("after %d attempts: %w", r.attempts, err)
}
// In main, stack decorators like layers:
var sender SMSSender = TwilioAdapter{client: client, from: fromNumber}
sender = RetryingSender{next: sender, attempts: 3}
sender = LoggingSender{next: sender, log: logger}HTTP middleware is the decorator pattern applied to http.Handler: func(next http.Handler) http.Handler wraps a handler with logging, auth or recovery. Because every layer has the same interface, the order is just the order you wrap them in. (Remember that retries are only safe for idempotent operations — the idempotency keys note explains why.)
Observer: tell interested parties something happened
Problem: when something happens — an order is paid — several unrelated parts of the system need to react (send a receipt, update analytics, notify the warehouse), and you don't want the payment code to know about all of them.
Solution: the subject keeps a list of subscribers and notifies each one; subscribers register themselves.
type OrderPaid = { orderId: string; amount: number }
type Listener<T> = (event: T) => void
class EventBus<T> {
private listeners = new Set<Listener<T>>()
subscribe(fn: Listener<T>): () => void {
this.listeners.add(fn)
return () => this.listeners.delete(fn) // unsubscribe
}
publish(event: T): void {
for (const fn of this.listeners) fn(event)
}
}
const orderPaid = new EventBus<OrderPaid>()
orderPaid.subscribe((e) => sendReceipt(e.orderId))
orderPaid.subscribe((e) => analytics.track('order_paid', e))You meet observers everywhere: DOM addEventListener, React's state subscriptions, Go channels fanned out to workers, and — across services — message brokers, where the pattern becomes publish/subscribe. The trade-off is visibility: it's no longer obvious from the payment code what happens after payment, and an exception in one listener can affect others. Keep in-process event buses small and well-named; for cross-service events, use a durable queue (see the queues and streams lesson).
Factory: centralise construction
Problem: creating an object requires a decision (which implementation?) or non-trivial setup, and you don't want that logic duplicated at every call site.
Solution: a function that encapsulates construction and returns an interface or a ready-to-use value.
func NewStorage(cfg Config) (BlobStore, error) {
switch cfg.StorageDriver {
case "s3":
return s3store.New(cfg.S3Bucket, cfg.S3Region)
case "local":
return localstore.New(cfg.LocalDir)
default:
return nil, fmt.Errorf("unknown storage driver %q", cfg.StorageDriver)
}
}In Go, every NewXxx constructor is a simple factory, and it's the idiomatic place to validate configuration and fill defaults. A factory that picks an implementation from config is the one place a switch on type names belongs: callers get a BlobStore and never see the choice. You rarely need the heavier "abstract factory" from the book.
Functional options and builders
Problem: a constructor has many optional settings. Positional parameters become unreadable, and a config struct can't distinguish "not set" from "set to zero".
Solution in Go — functional options: each option is a function that modifies the object being built.
type Server struct {
addr string
readTimeout time.Duration
maxBodyBytes int64
}
type Option func(*Server)
func WithReadTimeout(d time.Duration) Option { return func(s *Server) { s.readTimeout = d } }
func WithMaxBody(n int64) Option { return func(s *Server) { s.maxBodyBytes = n } }
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, readTimeout: 10 * time.Second, maxBodyBytes: 1 << 20} // defaults
for _, opt := range opts {
opt(s)
}
return s
}
srv := NewServer(":8080", WithReadTimeout(5*time.Second))Defaults live in one place, call sites are self-describing, and adding an option never breaks existing callers. In TypeScript, an options object with defaults ({ timeoutMs = 10_000, retries = 3 }: Options = {}) does the same job. The builder pattern — chained .withX() calls ending in .build() — suits objects assembled in many steps, like query builders and test data builders.
Repository and other names you'll hear
A few more patterns you'll meet constantly:
- Repository: an interface that looks like a collection of domain objects (
Find,Save) and hides the database behind it. Covered in the domain-driven design lesson. - Singleton: exactly one instance, globally reachable. Usually an anti-pattern — it's global mutable state with a design-pattern label. Create one instance in
mainand pass it in instead. - Template method: a base class defines the steps and subclasses fill some in. In Go and modern TypeScript, pass the variable steps as functions (strategy) instead of inheriting.
- Iterator: walk a collection without exposing its structure. Go 1.23 made this a language feature with range-over-func: a function returning
iter.Seq[T]works in afor rangeloop.
When patterns hurt
Patterns become a problem when they're applied because they exist, not because the problem is present. Warning signs:
- An interface with one implementation and no test fake, "for flexibility".
- A
FooFactoryProviderthat builds one type. - Five layers of decorators where one function would do.
- Code reviewers needing a diagram to follow a request through the code.
Introduce a pattern when you feel the pain it solves — the third if branch on payment provider, the second vendor SDK, the fourth cross-cutting concern — and refactor towards it. Patterns are best discovered in code, not imposed on it.
Practice
You have a WeatherClient interface with Current(ctx, city string) (Forecast, error), backed by a slow external API. Without changing the existing implementation, add a cache that serves repeated requests for the same city from memory for 10 minutes. Which pattern is this? Write it in Go, safe for concurrent use.
Show solution
It's a decorator: a type implementing WeatherClient that wraps another one.
type cachedForecast struct {
forecast Forecast
expires time.Time
}
type CachingWeatherClient struct {
next WeatherClient
ttl time.Duration
now func() time.Time
mu sync.Mutex
cache map[string]cachedForecast
}
func NewCachingWeatherClient(next WeatherClient, ttl time.Duration) *CachingWeatherClient {
return &CachingWeatherClient{next: next, ttl: ttl, now: time.Now, cache: make(map[string]cachedForecast)}
}
func (c *CachingWeatherClient) Current(ctx context.Context, city string) (Forecast, error) {
c.mu.Lock()
entry, ok := c.cache[city]
c.mu.Unlock()
if ok && c.now().Before(entry.expires) {
return entry.forecast, nil
}
f, err := c.next.Current(ctx, city)
if err != nil {
return Forecast{}, err // don't cache failures
}
c.mu.Lock()
c.cache[city] = cachedForecast{forecast: f, expires: c.now().Add(c.ttl)}
c.mu.Unlock()
return f, nil
}The mutex is not held during the slow call, so one city's request doesn't block others. Two concurrent misses for the same city will both call the API; if that matters, add golang.org/x/sync/singleflight to collapse them (the cache stampede note covers why). Injecting now lets a test check expiry without sleeping. Callers just receive a WeatherClient and never know caching exists.
Key takeaways
- A pattern's main value is a shared name for a known solution; many classic patterns become a function or small interface in Go and TypeScript.
- Strategy swaps behaviour; adapter translates an interface you have into one you need; decorator wraps a call with extra behaviour and stacks.
- Observer decouples "something happened" from "who cares", at the cost of visibility.
- Factories and functional options keep construction logic and defaults in one place.
- Avoid singletons; prefer passing one instance in. Introduce patterns when you feel the pain they solve, not before.
Check yourself
Quiz
0/4 correct · 4 left
1.HTTP middleware of the form func(next http.Handler) http.Handler is an example of which pattern?
2.Your payment code must call a vendor SDK whose methods don't match the PaymentGateway interface your domain defines. Which pattern keeps vendor types out of your domain code?
3.Why are functional options popular for Go constructors with many optional settings?
4.Which is the strongest sign a pattern is being misapplied?
Concepts in this lesson
One-page notes on each idea, for review or a quick lookup later.
- Idempotency keysMake a retried POST do its work exactly once by remembering the client’s request key.
- Cache stampedesOne expiring hot key, a thousand simultaneous misses — and three ways to stop it.
- Composition over inheritanceBuild behaviour by combining small objects that delegate to each other, rather than extending class hierarchies.
- Open–closed principleAdd new behaviour by adding code rather than editing tested code, at the points where variation is real.
- Ports and adaptersHexagonal architecture: the application defines ports and the outside world plugs in through adapters.
Finished reading?
Mark it complete to fill in your roadmap.