# 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.

Software Engineer roadmap · Code that lasts · 18 min read · https://levelcode.org/roadmaps/software-engineer/design-patterns

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.

```go
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:

```go
type Discount func(subtotal Money) Money
```

You'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](https://levelcode.org/roadmaps/software-engineer/solid).

## 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.

```go
// 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](https://levelcode.org/roadmaps/software-engineer/clean-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.

```go
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](https://levelcode.org/concepts/idempotency-keys) 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.

```ts
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](https://levelcode.org/roadmaps/system-design/queues-and-streams)).

## 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.

```go
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.

```go
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](https://levelcode.org/roadmaps/software-engineer/domain-driven-design).
- **Singleton:** exactly one instance, globally reachable. Usually an anti-pattern — it's global mutable state with a design-pattern label. Create one instance in `main` and 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 a `for range` loop.

## 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 `FooFactoryProvider` that 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.

**Solution**

It's a **decorator**: a type implementing `WeatherClient` that wraps another one.

```go
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](https://levelcode.org/concepts/cache-stampede) 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.

## Quiz

1. HTTP middleware of the form func(next http.Handler) http.Handler is an example of which pattern?

   A. Singleton
   B. Factory
   C. Decorator
   D. Observer

   **Answer: C.** Each middleware implements the same interface as the handler it wraps and adds behaviour before or after delegating, so layers can be stacked in any order.

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?

   A. An adapter that implements PaymentGateway by calling the SDK
   B. A singleton that holds the SDK client
   C. Inheriting from the SDK's client class
   D. An observer that listens for payment events

   **Answer: A.** An adapter translates between the interface you own and the one the vendor provides. Switching vendors then means writing a new adapter, not changing domain code.

3. Why are functional options popular for Go constructors with many optional settings?

   A. They make the constructor run faster
   B. They are required for concurrency safety
   C. They allow inheritance between option types
   D. Defaults live in one place, call sites are self-describing, and new options don't break existing callers

   **Answer: D.** Each With... function names what it sets, unspecified options keep their defaults, and adding an option is a new function rather than a signature change.

4. Which is the strongest sign a pattern is being misapplied?

   A. A decorator is used to add retries to an HTTP client
   B. An interface with a single implementation and no test double exists 'for future flexibility', behind a factory that builds that one type
   C. A strategy is passed to a sort function
   D. A NewServer constructor validates its configuration

   **Answer: B.** Indirection without a second implementation, a test need or a real variation point adds reading cost and no benefit. That's YAGNI applied to patterns.
