Stop the Cascade: How to Design Interfaces That Survive Change
The Expression Problem: A Design Dilemma
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
- Closed constructs localise logic but freeze the shape of data.
- Open constructs localise data but duplicate logic.
- Map your system’s volatility axes before choosing the construct.
- 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!