Skip to main content
CleanCodeMastery

Collapse Hierarchy: When Parent and Child Classes Become the Same

Learn the Collapse Hierarchy refactoring with a housing-society committee story, step-by-step merging of a superclass and subclass in TypeScript and C#, and the checks that tell you when a hierarchy has stopped earning its keep.

24 min read Updated June 11, 2026intermediate
refactoringinheritancehierarchylazy classclass designtypescriptcsharp

🏠 The committee and the sub-committee

Shanti Niwas is a housing society in Pune with forty flats. Like every society, it has a Managing Committee — seven members who handle maintenance money, the watchman's salary, water tankers, and the lift repairs. Mr. Sharma is the chairman. Mr. Patil keeps the accounts. And Mrs. Kulkarni, the secretary, writes the minutes of every meeting in a big red register.

Twelve years ago, the Ganesh festival in the society became very big. Pandal, lighting, prasad for two hundred people, a cultural night with the children performing. The Managing Committee said, "This is too much extra work," and formed a Festival Sub-Committee. New register (a green one), separate meetings on Saturday evenings, its own notice board near the lift. In that year it made full sense — the sub-committee had different members (young Mr. Deshpande from B-wing led it), clearly different duties, and a separate small budget.

But look at the society today, twelve years later. One by one, things changed:

  • The festival became smaller — two days instead of seven.
  • The decoration work went to a contractor, and the contractor deals with the main committee.
  • The festival account was merged into the society's main bank account "for audit convenience."
  • Mr. Deshpande sold his flat and moved to Bengaluru. His replacement on the sub-committee? Mr. Sharma. Then two more members left, replaced by Mr. Patil and Mrs. Kulkarni themselves.

Today, the Festival Sub-Committee has the same seven members, meets on the same Sunday, and discusses the same agenda as the main committee. The only real difference is paperwork. Mrs. Kulkarni must write the same minutes twice — once in the red register, once in the green one. Every notice goes on two boards. When the auditor comes, Mr. Patil explains the same numbers twice, under two committee names. When young Aditya bought flat 304 last year and asked, "Who do I talk to about the festival donation?", three neighbours gave him three different answers, because nobody is sure anymore which committee "owns" what.

Last month, Mrs. Kulkarni stood up in the AGM, held up both registers, and said the obvious thing: "These two books say the same thing in the same handwriting. Why are we maintaining two committees that are one committee? Let us merge the sub-committee back into the Managing Committee. One register, one notice board, one set of minutes. Nothing we do will change — only the extra paperwork will go."

Everyone agreed in two minutes. Even Mr. Sharma, who had founded the sub-committee, raised his hand first. That AGM resolution is today's refactoring. In code, when a subclass and its superclass have slowly become the same thing — same members, same behaviour, no real difference left — we merge them into one class. This is Collapse Hierarchy.

Figure 1: The society's journey from two committees doing double work to one committee doing it once

What is Collapse Hierarchy? 🧹

Collapse Hierarchy is a refactoring for class hierarchies that have outlived their reason. Once upon a time, the subclass added something meaningful — extra fields, overridden methods, a genuinely different variant. But code evolves the same way the society did. Methods got pulled up into the parent. Features were deleted. Differences were ironed out, one small change at a time. Nobody planned it, but one day the subclass is an empty shell: no new fields, no overrides, nothing but a different name for the same idea.

That empty shell is not free. Every level of inheritance costs the reader something:

  • One more type to learn. A new teammate sees two class names and assumes two concepts. There is only one. (Aditya assumed two committees meant two responsibilities. He was wrong, and it cost him three confusing conversations.)
  • One more file to open. Understanding "what does this object do?" means tracing two classes, often across two files.
  • One more decision for every change. "Should this new method go on the parent or the child?" is a question the team must answer again and again — for no benefit. Mrs. Kulkarni asked the same thing about every notice: which board, or both?
  • One more layer of indirection between the reader and the actual behaviour.

The refactoring itself is honest and simple: pick the class that survives, move any remaining members into it, point every reference at the survivor, and delete the other class. The society keeps one committee; the code keeps one class.

💡

One-line summary: when a superclass and subclass are no longer different enough to be two classes, merge them into one — move the surviving members into the keeper, redirect every reference, and delete the empty shell.

It helps to know this refactoring's family. Collapse Hierarchy is the exact inverse of Extract Subclass: one creates a child class when a real variant appears; the other deletes the child when the variant disappears. And it is the hierarchy-flavoured sibling of Inline Class, which performs the same "merge away the tiny class" move on two separate collaborators instead of a parent and child. If you ever collapse a hierarchy and later a genuine variant returns, do not feel bad — Extract Subclass will rebuild it in an afternoon, and this time it will exist because it is needed.

Figure 2: The Collapse Hierarchy idea map — what it is, when to use it, and its close relatives

College corner: there is a deeper reason senior engineers dislike unnecessary hierarchy levels, and it has a famous name — the fragile base class problem. Every subclass is coupled not just to its parent's public interface but to its private implementation choices: which method calls which, in what order, with what side effects. Each extra level of inheritance multiplies the surfaces along which an innocent-looking change in one class can silently break another. An empty subclass gives you zero benefit while still keeping you signed up for all of that risk. Collapsing the hierarchy is not just tidiness; it is deleting a coupling channel that could one day carry a bug.

When do we need it? 🔍

Watch for these signs in your codebase:

  • The subclass adds nothing. Open the child class and count: zero new fields, zero new methods, zero overrides — or only overrides that call super and return its result unchanged. This is the Lazy Class smell wearing the costume of inheritance: a class that does not earn its upkeep.
  • The hierarchy was built for a future that never came. "We will surely need PremiumCustomer and RegularCustomer someday" — and five years later both behave identically. That is Speculative Generality, and collapsing is its cure.
  • The parent and child are near copies of each other. Sometimes the convergence shows up as Duplicate Code between the two levels: the child re-declares what the parent already provides. After deleting the duplication, you often find nothing remains in the child at all.
  • Every change touches both classes. When a single concept lives on two levels, one bug fix means two edits and two reviews. The hierarchy has become friction — Mrs. Kulkarni's two registers.
  • Refactoring eroded the difference. A series of perfectly good Pull Up Method and Pull Up Field moves consolidated everything in the parent. Each step was right; the end state simply has one class too many.

Now the careful part — the signs that you should not collapse:

  • The subclass still overrides real behaviour, or callers depend on it polymorphically (they pass SalariedEmployee where the type matters). A working variant is not a lazy class.
  • The thinness is a symptom, not the disease. If the child is empty because behaviour was wrongly pulled up into the parent — behaviour that only this child should have — the fix is to push it back down, not to merge the classes.
  • The opposite smell is present. If the subclass refuses parts of its parent — inherits methods it does not want — that is Refused Bequest, and the cure is Replace Inheritance with Delegation, not a merge. Collapse Hierarchy is for classes that became the same; Refused Bequest is for classes that were never truly related.

A quick way to remember the difference between the look-alike situations:

SituationWhat you seeRight move
Child adds nothing, parent and child are one conceptEmpty subclass, same behaviourCollapse Hierarchy (this lesson)
Child rejects or stubs out parent membersthrow new Error("not supported") overridesReplace Inheritance with Delegation
Two collaborating classes, one is tinyA class with one field and one forwarding methodInline Class
Child is empty because behaviour was wrongly liftedParent contains child-only logicPush Down Method, keep the hierarchy
A real variant might return next quarterProduct roadmap genuinely lists itWait, or collapse and re-extract later

How do you measure "adds nothing" honestly? Count the members. Here is what the audit of a typical collapse candidate looks like:

Figure 3: A member-by-member audit of a typical collapse candidate — almost everything is inherited unchanged

If the "genuinely new behaviour" slice is zero — or becomes zero after you delete the trivial overrides — the hierarchy has stopped earning its keep.

Before and after at a glance

Here is the smallest honest picture of the problem. An Employee class, and a SalariedEmployee child that once carried hourly-versus-salaried logic — until the hourly variant was removed from the product two years ago:

// BEFORE: a two-level hierarchy holding exactly one concept
class Employee {
  constructor(
    protected name: string,
    protected monthlySalary: number,
  ) {}
 
  pay(): number {
    return this.monthlySalary;
  }
}
 
// Adds nothing: no fields, no methods, no overrides.
// It exists only because it used to mean something.
class SalariedEmployee extends Employee {}
 
// And callers must remember which name to use where...
const emp = new SalariedEmployee("Asha", 52000);

And after the collapse — one class, one name, one place to look:

// AFTER: the hierarchy is gone; the concept remains
class Employee {
  constructor(
    protected name: string,
    protected monthlySalary: number,
  ) {}
 
  pay(): number {
    return this.monthlySalary;
  }
}
 
// SalariedEmployee is deleted. Every reference now says Employee.
const emp = new Employee("Asha", 52000);

Notice what did not change: behaviour. pay() returns the same number before and after. Collapse Hierarchy is purely structural — it deletes ceremony, never function. If any test changes its result after this refactoring, something went wrong.

Figure 4: Before the collapse, an empty shell hangs below the real class; after, one class carries the whole concept

It is worth seeing what the empty shell costs at runtime and in the reader's head. Every call still has to be understood through two classes, even though only one does anything:

Figure 5: Before the collapse, every reader must mentally walk through the empty subclass to reach the real behaviour

The "middle column" in that diagram is Mrs. Kulkarni's green register: a stop on the journey that records nothing new.

Step-by-step, the safe way 🪜

Like Mrs. Kulkarni's merger — pass the resolution first, move the registers next, and only then retire the old notice board. Small steps, tests green after each one.

Step 1: Choose the survivor. Usually the superclass survives, because most code refers to the general name. But the deciding question is: which name best describes the merged concept? If the whole codebase talks about SalariedEmployee and Employee is barely referenced, keep the child's name instead. Also check the parent's other children: if Employee has a still-living Contractor subclass, the parent must survive — merging the parent into one child would make Contractor inherit from SalariedEmployee, which is nonsense (this is the Liskov-violation trap from the FAQ).

Step 2: Move the remaining members into the survivor. If you are deleting the subclass, use Pull Up Field and Pull Up Method to lift anything still living in the child into the parent. If you are deleting the superclass, push members down instead. Do this one member at a time, compiling and testing between moves.

// Intermediate state: the child still exists, but it is now COMPLETELY empty.
// Everything has been pulled up. Tests are green. Nothing is deleted yet.
class Employee {
  constructor(protected name: string, protected monthlySalary: number) {}
  pay(): number { return this.monthlySalary; }
  bonusFor(festival: string): number {        // pulled up from the child
    return festival === "Diwali" ? this.monthlySalary * 0.1 : 0;
  }
}
 
class SalariedEmployee extends Employee {}    // empty — ready for deletion

Step 3: Redirect every reference. Find every place that names the doomed class — new SalariedEmployee(...), type annotations, instanceof checks, import statements, test files — and change it to the survivor. Your IDE's "find all usages" is your friend here; do not trust plain text search alone, because comments and strings can hide references too.

Step 4: Delete the empty class. With zero references remaining, deletion is a non-event. The compiler will confirm.

Step 5: Compile and run the whole test suite. Not just the unit tests near the class — the whole suite. A hierarchy touches construction, type checks, and serialization in places you may not remember.

Step 6: Handle public API politely. If the deleted type was exported from a library, keep a deprecated alias (/** @deprecated Use Employee */ export const SalariedEmployee = Employee; or a thin empty subclass marked obsolete) for one release cycle before final removal.

The whole journey, as a state machine you can pin above your desk:

Figure 6: The safe states of a collapse — never jump from two classes straight to deletion
⚠️

The most common mistake is collapsing a subclass that is only mostly empty. One small override — a toString, a validation tweak, a default value in the constructor — silently changes behaviour when it disappears. Before Step 2, diff the child against the parent member by member, and write a quick characterization test around any override you find. Either the override is dead (delete it first, prove tests stay green) or it is alive (and the hierarchy may deserve to live too).

A bigger real-life example 📨

Here is a story that happens in almost every codebase that survives a few years. A notification module once supported email and SMS:

// THE PAST: a hierarchy with a real reason to exist
abstract class Notifier {
  constructor(protected recipient: string) {}
  abstract send(message: string): void;
}
 
class EmailNotifier extends Notifier {
  send(message: string): void { /* SMTP magic */ }
}
 
class SmsNotifier extends Notifier {
  send(message: string): void { /* telecom gateway magic */ }
}

Then the SMS gateway contract ended. SmsNotifier was deleted. Over the following year, helpful teammates pulled shared logic upward: the retry loop moved to the parent, the logging moved to the parent, and finally someone made send concrete in the parent "to reduce duplication." Each move was sensible. The result is this:

// THE PRESENT: a hierarchy with no reason left
class Notifier {
  constructor(protected recipient: string) {}
 
  send(message: string): void {
    this.logAttempt(message);
    this.deliverViaSmtp(message);   // the "abstract" part is long gone
  }
 
  protected logAttempt(message: string): void { /* ... */ }
  protected deliverViaSmtp(message: string): void { /* SMTP magic */ }
}
 
// What does this add? Nothing. It is the festival sub-committee.
class EmailNotifier extends Notifier {}

Every new developer who opens this module asks the same two questions: "What other notifiers are there?" (none) and "Should I construct Notifier or EmailNotifier?" (it does not matter — which is exactly the problem). Two names, one meaning.

The collapse takes ten minutes. Survivor choice is interesting here: Notifier is the general name, but the class is now SMTP-specific through and through. The honest merged name is EmailNotifier — names should tell the truth. So this time the child survives, the parent's members are pushed down, and Notifier is deleted:

// AFTER: one class, and its name tells the truth
class EmailNotifier {
  constructor(private recipient: string) {}
 
  send(message: string): void {
    this.logAttempt(message);
    this.deliverViaSmtp(message);
  }
 
  private logAttempt(message: string): void { /* ... */ }
  private deliverViaSmtp(message: string): void { /* SMTP magic */ }
}

Note the small bonus: with no subclass left, protected members became private. Collapsing a hierarchy often lets you tighten visibility, which shrinks the class's surface area even further. And if SMS ever returns? Extract Subclass — or better, an interface with two implementations — will be waiting.

Figure 7: The decision path — collapse only when the child adds nothing and no sibling needs the parent

The same refactoring in C# 🟣

The mechanics translate one-to-one to C#. Here is a reporting module mid-collapse:

// BEFORE: PdfReport once differed; today it is an empty costume
public class Report
{
    protected readonly string Title;
    public Report(string title) => Title = title;
 
    public virtual byte[] Render()
    {
        // years ago this was abstract; now it renders PDF directly
        return PdfEngine.Render(Title);
    }
}
 
public class PdfReport : Report          // no members at all
{
    public PdfReport(string title) : base(title) { }
}
// AFTER: one class; constructor chaining ceremony gone with it
public sealed class Report
{
    private readonly string _title;
    public Report(string title) => _title = title;
 
    public byte[] Render() => PdfEngine.Render(_title);
}

Three C#-specific notes worth pausing on:

  • You can now seal the class. With no children, sealed documents the design decision and lets the runtime devirtualize calls. virtual on Render was pure cost — remove it.
  • Constructor chaining disappears. Every : base(title) line in the child was ceremony that the merge deletes for free. In hierarchies with many constructor parameters, this alone pays for the refactoring.
  • Mind public APIs and serialization. If PdfReport was a NuGet-published type, mark it [Obsolete("Use Report")] for one release before deleting. And if any serialized payloads or EF Core discriminator columns stored the type name PdfReport, add a mapping so old data still loads.

And a quick Python flavour, because the audit step is language-independent. In Python an "empty" subclass is literally one line, which makes it even easier to miss:

class Report:
    def __init__(self, title: str) -> None:
        self.title = title
 
    def render(self) -> bytes:
        return pdf_engine.render(self.title)
 
 
class PdfReport(Report):    # the whole class. Nothing. Collapse it.
    pass

A one-line pass subclass survives code review again and again because it looks harmless. It is the green register: harmless, and pure upkeep.

Is the hierarchy earning its keep? 📊

Two pictures answer that question better than any argument in a code review.

First, plot the history. Pull up the git log of the subclass and count how many genuinely new members it contributed each year. A dying hierarchy shows a curve like this:

Figure 8: New members contributed by the subclass per year — the variant quietly died around year three

Second, place the class on the keep-or-collapse map. The two axes that matter are how different the child really is, and how many other children depend on the parent:

Figure 9: The keep-or-collapse map — bottom-left is where empty shells live

The festival sub-committee sits firmly in the collapse corner. A Shape base class with Circle, Square, and Triangle children that each override area() sits in the opposite, healthy corner — never collapse that.

College corner: notice the connection to the Liskov Substitution Principle (LSP) here. LSP says a subclass object must be usable anywhere its parent type is expected, with no surprises. An empty subclass passes LSP trivially — it is its parent, behaviourally. That is exactly the evidence against it: LSP is supposed to be a constraint that real variants must work to satisfy. When passing LSP costs the child nothing because the child does nothing, the type system is carrying a distinction that the behaviour does not. Collapse makes the type system tell the truth again. The reverse trap also lives here: choosing the wrong survivor can create an LSP violation for sibling classes, which is why Step 1 checks every other child first.

IDE support 🛠️

No IDE has a single button called "Collapse Hierarchy," because the move is a composite of smaller refactorings — but the ingredients are all automated:

  • IntelliJ IDEA / Rider: Refactor → Pull Members Up and Push Members Down move fields and methods between the levels with checkbox precision. Safe Delete (Alt+Delete) refuses to remove a class while usages remain and lists every blocker. IntelliJ also offers Inline Super Class, which merges a parent into its child in one guided dialog — the exact "child survives" variant of this refactoring.
  • ReSharper (Visual Studio): Pull Members Up / Push Members Down dialogs for C#, plus Safe Delete, which finds and previews every reference before deletion.
  • VS Code (TypeScript): no dedicated hierarchy refactorings, but Find All References (Shift+F12) and Rename Symbol (F2) cover Step 3 reliably. A practical trick: rename the doomed class to the survivor's name with F2 — the language service rewrites every reference — then delete the now-duplicate declaration.

Whatever the tool, the safety rule is the same: the compiler and the test suite are the real referees. Run both after every member you move.

Benefits and risks ⚖️

BenefitsRisks / costs
One concept lives in one class — half the files to read, zero "which level?" decisionsCollapsing a child that still had a live override silently changes behaviour — diff member by member first
Removes a layer of indirection; navigation and debugging get shorterWrong survivor choice can break the Liskov principle for sibling subclasses (Plane inheriting from Car)
Future edits touch one class instead of two — less shotgun surgeryDeleting a public type breaks external consumers — deprecate before removing
Visibility can tighten (protectedprivate), classes can be sealedIf the child was empty because behaviour was wrongly pulled up, the right fix is push-down, not merge
Cures Lazy Class and Speculative Generality at their rootIf a real variant returns later, you must re-extract the subclass (cheap, but plan for it)
One less coupling channel for fragile-base-class breakageSerialized type names and DB discriminators may reference the deleted class — map old data

Which smells does it cure? 👃

SmellHow Collapse Hierarchy helps
Lazy ClassThe empty subclass that earns nothing is merged away entirely
Speculative GeneralityThe "we might need variants someday" hierarchy is folded back to today's real shape
Duplicate CodeParent-child near-duplication collapses into a single definition
Shotgun SurgeryChanges to one concept stop requiring edits at two hierarchy levels
Middle-layer indirectionReaders reach the actual behaviour without hopping through an empty class

Quick revision box 📦

+------------------------------------------------------------------+
|        COLLAPSE HIERARCHY - REVISION CARD                        |
+------------------------------------------------------------------+
| Problem  : superclass and subclass have CONVERGED -              |
|            the child adds no fields, methods, or overrides.      |
|            Two names, one concept. (The festival sub-committee   |
|            with the same members as the main committee.)         |
|                                                                  |
| Solution : 1. pick the SURVIVOR (best-truth name; check          |
|               sibling subclasses first!)                         |
|            2. pull up / push down remaining members              |
|            3. redirect EVERY reference to the survivor           |
|            4. delete the empty class                             |
|            5. full test suite; deprecate if API was public       |
|                                                                  |
| Inverse  : Extract Subclass (rebuild a child if a real           |
|            variant returns)                                      |
| Cousin   : Inline Class (same merge, for collaborators)          |
| NOT for  : children with live overrides (keep them) or           |
|            Refused Bequest (use delegation instead)              |
+------------------------------------------------------------------+

Practice exercise ✏️

Your turn. A small banking codebase contains this pair, left over from an era when the bank had two account products:

class Account {
  constructor(
    protected holder: string,
    protected balance: number,
    protected interestRate: number,
  ) {}
 
  deposit(amount: number): void { this.balance += amount; }
  withdraw(amount: number): void {
    if (amount > this.balance) throw new Error("Insufficient funds");
    this.balance -= amount;
  }
  yearlyInterest(): number { return this.balance * this.interestRate; }
}
 
class SavingsAccount extends Account {
  // The current-account product was discontinued in 2021.
  // Interest logic was pulled up to Account around the same time.
  yearlyInterest(): number {
    return super.yearlyInterest();   // forwards and adds nothing
  }
}

Work through it:

  1. Audit the child member by member. The yearlyInterest override calls super and returns the result unchanged — is it dead ceremony or does it alter behaviour? Delete it first and prove the tests stay green.
  2. Decide the survivor. Search the (imaginary) codebase in your head: tellers, statements, and tests all say "savings account," and no other subclass of Account exists. Which name tells the truth now? Justify your choice in one sentence.
  3. Perform the collapse in the safe order: move members, redirect every new, every type annotation, every instanceof, then delete the empty class.
  4. Tighten what the collapse unlocked: can any protected member become private? In C#, could the class become sealed?
  5. Draw your own version of Figure 6 for this collapse — which state would you be in after step 3, and what event moves you forward?
  6. Bonus thinking: suppose next year the bank launches a "Fixed Deposit" product with a lock-in period and a penalty on early withdrawal. Would you re-extract a subclass, or reach for composition with a WithdrawalPolicy object? One sentence on which and why.

If your answer to step 6 mentioned that a lock-in rule is a behaviour that varies and might be swapped or combined — and so composition deserves the first look — you are already thinking like the next two lessons in this series. Mrs. Kulkarni would approve: one register, one truth, no extra paperwork. Well done.

Frequently asked questions

Which class should survive the collapse — the superclass or the subclass?
Usually the superclass, because other code most often refers to the more general name. But the real rule is simpler: keep whichever name best describes the merged concept. If the subclass name is what the team actually says in conversation, keep that one and pull the parent's members down instead.
How is Collapse Hierarchy different from Inline Class?
Both merge two classes into one. Collapse Hierarchy works on a parent and child joined by inheritance — you remove a level of the hierarchy. Inline Class works on two separate collaborating classes where one has become too small to justify its existence. Same spirit, different relationship between the classes.
What if the subclass still overrides one or two methods?
Then it is still doing real work and you should pause. Ask whether those overrides represent a genuine variant that callers rely on polymorphically. If yes, the hierarchy is earning its keep — do not collapse. If the overrides are trivial or dead, remove them first, run the tests, and then collapse with confidence.
Can collapsing a hierarchy break the Liskov Substitution Principle?
Yes, if you collapse the wrong way. If you merge a general superclass into one specific subclass, any other subclasses suddenly inherit from that specific class — for example, merging Transport into Car would make Plane a child of Car. Always check every other child of the superclass before deciding which class survives.
The subclass is part of our public API. Can I still collapse it?
You can, but gently. Deleting a public type is a breaking change for everyone who imports it. The polite path is to move all behaviour into the survivor, keep the old type as a thin deprecated alias for one release cycle, announce it, and only then delete it.

Further reading

Related Lessons