Subscribe for more posts like this →

Stop the Cascade: How to Design Interfaces That Survive Change

The Expression Problem: A Design Dilemma

Stop the Cascade: How to Design Interfaces That Survive Change

The Expression Problem: A Design Dilemma

Most bugs appear when a change ripples through code you thought was finished. The cure is knowing what to keep closed and what to leave open.

🕰 A 60‑Second War Story

Six months after launch, FinFast had to add a new CryptoAccount variant.

What looked like a two‑hour task broke 17 files — nine of them hidden deep in the reporting layer.

They’d chosen an interface style that fought the axis of change that mattered most.

That tension has a name.

The Expression Problem, in Plain English

“Should my design make it trivial to add new variants or new operations?”
Phil Wadler (paraphrased)

Most languages force a trade‑off:

A Visual Map of Volatility

Closed Interfaces in Practice

Go: Tagged Structs + Switch Logic

type Expr struct { 
    Kind       string 
    Value      int 
    Left, Right *Expr 
} 
func Eval(e Expr) int { 
    switch e.Kind { 
    case "const": 
        return e.Value 
    case "add": 
        return Eval(*e.Left) + Eval(*e.Right) 
    } 
    panic("unsupported variant") 
}

✅ Central place to add behaviours (PrettyPrint, Optimise, …).

❌ Every new variant forces you to touch every switch.

Java: Sealed Types + Pattern Matching

sealed interface Expr permits Const, Add {} 
record Const(int value) implements Expr {} 
record Add(Expr left, Expr right) implements Expr {} 
int eval(Expr e) { 
    return switch (e) { 
        case Const c -> c.value(); 
        case Add a -> eval(c.left()) + eval(c.right()); 
    } 
}

Strong exhaustiveness checking; same trade‑off.

TypeScript: Discriminated Unions

type Const  = { kind: "const", value: number }; 
type Add    = { kind: "add", left: Expr, right: Expr }; 
type Expr   = Const | Add; 
function eval(e: Expr): number { 
  switch (e.kind) { 
    case "const": return e.value; 
    case "add":   return eval(e.left) + eval(e.right); 
  } 
}

Great until someone adds “mul” — then every switch must change.

Open Interfaces in Practice

Go: Interface Polymorphism

type Expr interface{ Eval() int } 
type Const struct{ Value int } 
func (c Const) Eval() int { return c.Value } 
type Add struct{ Left, Right Expr } 
func (a Add) Eval() int { return a.Left.Eval() + a.Right.Eval() }

✅ Third parties add new variants freely.

❌ Adding a new behaviour such as PrettyPrint means touching all variants.

Java: Classic OO Hierarchy

interface Expr { int eval(); } 
final class Const implements Expr { 
    int value; 
    public int eval() { return value; } 
}

Familiar pattern — same upsides and downsides as Go.

TypeScript: Class-Based Interface

interface Expr { 
  eval(): number; 
} 
class Const implements Expr { 
  constructor(public value: number) {} 
  eval() { return this.value; } 
}

Again, variants scale; behaviours don’t.

Choosing Wisely: The Interface Volatility Checklist

Rule of thumb — Close what is stable; open what is volatile.

Key Takeaways

  1. Closed constructs localise logic but freeze the shape of data.
  2. Open constructs localise data but duplicate logic.
  3. Map your system’s volatility axes before choosing the construct.
  4. Hybrids (Visitor, Decorator, Middleware, Function Registry) tame both axes when necessary.

Up Next (Part 2)

  • Real‑world domains — reporting, compliance, plug‑in ecosystems
  • Hybrid patterns that handle both axes simultaneously

Enjoyed this?

  • Follow @maneesh‑chaturvedi to catch Part 2 as soon as it drops.
  • Have a friend wrestling with brittle interfaces? Share this post — save them a refactor!

Read more

Subscribe for more posts like this →