skip to content

A hierarchy has grown to dozens of subclasses because behavior varies along several independent axes (e.g. storage backend × serialization format × compression). How would you restructure it, and what is this failure mode called?

level: middleimportance: should knowfreq 48%

answer

  1. product vs sum: 2×3×2=12 classes → 7 parts
  2. one superclass chain = one axis only
  3. Strategy per axis; Bridge for two hierarchies
  4. Decorator when options stack
  5. second adjective in a class name = warning

basics

~20 s

It's a combinatorial subclass explosion: with inheritance you need one class per combination (2×3×2 = 12). Replace each axis with a composed collaborator chosen by interface — a Strategy per axis — so you write 2+3+2 = 7 parts and assemble them.

solid answer

~60 s

The failure mode is **combinatorial subclass explosion** (also "class explosion" / "multi-dimensional hierarchy"). Inheritance gives you exactly one axis of variation — the single superclass chain — so encoding *n* independent axes forces the product of their options into class names like `S3JsonGzipStore`. Adding one new option to any axis multiplies the class count and duplicates code across siblings. The fix is to give each axis its own abstraction and compose them: a `Storage` interface, a `Serializer` interface, a `Compressor` interface, and one concrete class that holds all three and is configured at construction. This is the **Strategy** pattern applied per axis; when one axis is "abstraction" and another is "implementation" (e.g. shape vs. rendering backend) the same restructuring is called the **Bridge** pattern; stacking optional behaviors on one axis is **Decorator**. Benefits: linear growth, runtime configurability, each part unit-testable in isolation. Costs: more wiring (usually pushed into a factory or DI container), and indirection that makes "what actually runs" less obvious from a stack-trace-free reading of the code.

code

pseudocode · 12 lines
pseudocode
// 12 classes: S3JsonGzipStore, S3XmlPlainStore, LocalJsonGzipStore, ...

// -> 7 parts + 1 orchestrator
interface Storage    { write(key, bytes) }
interface Serializer { encode(obj): bytes }
interface Compressor { compress(bytes): bytes }

class DocumentStore(s: Storage, ser: Serializer, c: Compressor) {
  fun save(key, obj) = s.write(key, c.compress(ser.encode(obj)))
}

val store = DocumentStore(S3Storage(), JsonSerializer(), GzipCompressor())

go deeper

for a junior

Say the count multiplies (product) with inheritance and only adds (sum) with composition, and give the 12-vs-7 arithmetic.

for a middle

Name the axes, extract one interface per axis, inject them, and name Strategy/Decorator; mention the wiring moves to a factory or DI.

for a senior

Add Bridge for two co-varying hierarchies, discuss invalid-combination risk and how a factory constrains it, and state when a small hierarchy is still the right call.

for a principal

Discuss migration of a live public hierarchy — shim classes, deprecation windows, keeping the assembly point single so configuration stays governable — and the organizational signal that a second adjective in a class name predicts the explosion.

## The symptom Class names that read like a truth table — `S3JsonGzipStore`, `S3JsonPlainStore`, `LocalXmlGzipStore` — or an abstract base with a `switch` over a `type` field in several methods. Adding a fourth format means writing (or copy-pasting) 4 more classes. Bug fixes must be applied to several siblings. ## Why inheritance can't express it A class has exactly **one** superclass chain. That chain is a single axis of variation. If your domain varies along *k* independent axes with *n₁…n_k* options, inheritance forces you to flatten them into one chain, and the number of leaf classes is the **product** n₁×n₂×…×n_k. Composition lets each axis be a separate field, so you author the **sum** n₁+n₂+…+n_k parts and pick one per axis at assembly time. 2 storages × 3 formats × 2 compressions: **12 classes** vs **7 parts**. Add a 4th format: 16 vs 8. Add a 4th axis with 3 options: 48 vs 11. ## The restructuring, step by step 1. **Name the axes.** Read the subclass names and the overridden methods; each recurring word is usually an axis. 2. **Extract one interface per axis**, narrow and behavior-only: `Storage { read(key); write(key, bytes) }`, `Serializer { encode(obj): bytes; decode(bytes): obj }`, `Compressor { compress(bytes); decompress(bytes) }`. 3. **Move each sibling's axis-specific code into a small implementation** of the matching interface. If two siblings had identical serialization code, they collapse into one `JsonSerializer`. 4. **Write one concrete orchestrator** holding all three and expressing the *workflow*: `store(obj) = storage.write(key, compressor.compress(serializer.encode(obj)))`. 5. **Move the choosing to the edge** — a factory, builder, or DI configuration reads config and assembles the object. Now "which combination" is data, not a type. 6. **Delete the hierarchy** (or keep a thin deprecated shim if it's public API). ## The pattern names you should be able to say - **Strategy** — one interchangeable algorithm per axis, injected. The general answer here. - **Bridge** — explicitly designed to keep an *abstraction* hierarchy and an *implementation* hierarchy varying independently (`Shape` × `Renderer`); it is the GoF name for exactly this decoupling of two axes. - **Decorator** — for one axis where options *stack* rather than exclude: `Compressed(Encrypted(Buffered(stream)))`. Powerful when order matters and n options give 2ⁿ useful combinations that no class list could enumerate. - **Mixins / traits** (language-level) — reusable behavior bundles attached to a class without a single-parent chain; they reduce duplication but are still resolved at compile time and can reintroduce ambiguity (which trait's method wins?) — the classic **diamond problem**, resolved by explicit linearization rules in languages that support it. ## Trade-offs to state out loud - **Wiring cost.** Twelve implicit combinations become explicit construction code. A factory or DI container absorbs this; without one, the wiring scatters. - **Indirection.** Reading a single class no longer tells you the full behavior; you need to know the injected parts. Good naming and a single assembly point mitigate it. - **Interface design pressure.** If your axes aren't truly independent (e.g. one storage only supports one format), forcing them apart creates invalid combinations. Guard with a factory that only produces valid assemblies, or reconsider whether it's really two axes. - **Not always worth it.** With one axis and three subclasses that will never multiply, a small hierarchy is fine. The refactoring pays off when a *second independent axis* appears — that's the trigger. ## Detecting it early The warning sign is the *second* adjective in a class name. `GzipStore` is fine; `GzipJsonStore` means two axes are already flattened into one chain, and the third adjective is coming.

  • When would you reach for Decorator instead of a Strategy field per axis?
    When options on one axis *stack* and their order matters — buffering, encryption, compression, metrics on a stream. Decorators nest arbitrarily and give 2ⁿ orderings; a single strategy field can only hold one choice.
  • What new risk does splitting axes into independent strategies introduce?
    Invalid combinations. If storage A cannot actually serve format B, the type system no longer prevents assembling them. Constrain via a factory that only builds valid tuples, or validate at construction and fail fast.
  • Do mixins/traits solve this as well as composition does?
    Partly. They remove duplication without a single-parent chain, but the mix is fixed at compile time, so runtime reconfiguration is impossible, and overlapping trait members need explicit conflict-resolution/linearization rules.

Building a wardrobe by sewing every jacket-shirt-trousers combination into a single garment. Separate pieces you mix and match give you the same outfits from a fraction of the wardrobe.

context