Collapse Hierarchy
Intent
Section titled “Intent”Collapse Hierarchy merges a superclass and a subclass that are no longer different enough to justify being two separate types. The members of one are folded into the other, the now-redundant class is deleted, and every reader is spared a layer of indirection that bought them nothing.
The smell
Section titled “The smell”Hierarchies erode. Refactorings pull members up and push them down until a subclass adds almost nothing — maybe one trivial field, maybe an override that now matches the parent. This is Lazy Class wearing a subclass costume: an extra type, an extra file, an extra hop for every reader, all to express a distinction that has quietly disappeared. When a subclass and its superclass have become near-twins, the honest move is to make them one.
Before → After
Section titled “Before → After”A Salesperson subclass once meaningfully extended Employee, but successive refactorings left it holding a single field that Employee could just as well own. We collapse them.
// Before — Salesperson adds almost nothing over Employeeclass Employee { constructor(public name: string, public monthlyPay: number) {} annualCost(): number { return this.monthlyPay * 12; }}
class Salesperson extends Employee { constructor(name: string, monthlyPay: number, public region: string) { super(name, monthlyPay); }}
// After — one Employee carries the region field directlyclass Employee { constructor( public name: string, public monthlyPay: number, public region: string = "", ) {} annualCost(): number { return this.monthlyPay * 12; }}# Before — Salesperson adds almost nothing over Employeeclass Employee: def __init__(self, name, monthly_pay): self.name = name self.monthly_pay = monthly_pay
def annual_cost(self): return self.monthly_pay * 12
class Salesperson(Employee): def __init__(self, name, monthly_pay, region): super().__init__(name, monthly_pay) self.region = region
# After — one Employee carries the region field directlyclass Employee: def __init__(self, name, monthly_pay, region=""): self.name = name self.monthly_pay = monthly_pay self.region = region
def annual_cost(self): return self.monthly_pay * 12// Go has no class hierarchy. The equivalent "hierarchy" is a thin struct// that embeds a base just to add one field. Collapsing means folding that// field into the base struct and deleting the wrapper.
// Before — Salesperson embeds Employee only to add Regiontype Employee struct { Name string MonthlyPay float64}
func (e Employee) AnnualCost() float64 { return e.MonthlyPay * 12 }
type Salesperson struct { Employee Region string}
// After — one Employee struct carries Region directlytype Employee struct { Name string MonthlyPay float64 Region string}
func (e Employee) AnnualCost() float64 { return e.MonthlyPay * 12 }// Rust has no inheritance. The equivalent is a wrapper struct that holds// a base struct just to bolt on one field. Collapsing folds the field into// the base and removes the wrapper.
// Before — Salesperson wraps Employee only to add a regionstruct Employee { name: String, monthly_pay: f64,}
impl Employee { fn annual_cost(&self) -> f64 { self.monthly_pay * 12.0 }}
struct Salesperson { employee: Employee, region: String,}
// After — one Employee struct carries region directlystruct Employee { name: String, monthly_pay: f64, region: String,}
impl Employee { fn annual_cost(&self) -> f64 { self.monthly_pay * 12.0 }}classDiagram
class Employee {
+name string
+grade number
+annualCost() number
}
note for Employee "Salesperson merged back in — one type now" Mechanics
Section titled “Mechanics”- Decide which class survives. Usually the superclass absorbs the subclass, but if the subclass name reads better for the merged concept, keep it instead.
- Use Pull Up and Push Down to move all fields and methods into the single surviving class, so the doomed class is left empty.
- Repoint every reference and constructor call from the deleted class to the survivor. Run your tests.
- Delete the now-empty class (and remove the embedding/wrapper in Go and Rust).
- Run your tests one final time. The program should behave exactly as before, with one fewer type to navigate.
When to use / trade-offs
Section titled “When to use / trade-offs”Collapse a hierarchy when a subclass adds so little that the extra type is pure overhead — no meaningful behaviour, no real distinction, just indirection. Removing it makes the code shorter and flatter, and readers stop hopping between parent and child to assemble one concept.
The trade-off is reversibility: if the distinction returns later, you will re-extract a subclass (or interface). That is fine — Collapse Hierarchy is the natural inverse of Extract Superclass, and refactoring is meant to flow both ways as your understanding changes. In Go and Rust there was never a class hierarchy to begin with, so “collapse” simply means deleting a needless wrapper struct and inlining its one field.