C# Inheritance in Depth: The Fragile Base Class Problem
You know "is-a." Now let's talk about what happens five levels deep, three years later, when four teams depend on the base class.
You already know inheritance: a base class, a derived class, virtual and override. This lesson isn't about that syntax. It's about a very specific, very common failure mode that shows up once a hierarchy has been alive for a while:
// Year 1: a clean, simple hierarchy
public class NotificationHandler
{
public virtual void Handle(Notification n) => Send(n);
protected void Send(Notification n) => Console.WriteLine($"Sending: {n.Message}");
}
public class EmailNotificationHandler : NotificationHandler { /* email-specific overrides */ }
// Year 3: nine subclasses later, someone needs to add retry logic to the BASE class
public class NotificationHandler
{
public virtual void Handle(Notification n)
{
for (int attempt = 0; attempt < 3; attempt++)
{
try { Send(n); return; }
catch when (attempt < 2) { /* retry */ }
}
}
protected void Send(Notification n) => Console.WriteLine($"Sending: {n.Message}");
}
// Every one of the nine subclasses that overrode Handle() and called base.Handle()
// now silently retries three times — some of them shouldn't (e.g. a handler that's
// already idempotent-unsafe). Nobody touched those nine files. They all just changed behavior.
Nothing here is a bug in the traditional sense — every line compiles, every existing test might even still pass. But the base class's author changed the behavior of nine classes they may not have known existed, written by people who no longer work on the team. This is the fragile base class problem, and it's the central issue this lesson tackles.
In this lesson, you'll go deeper into inheritance: how deep hierarchies actually behave in real codebases, what the fragile base class problem is and how to defend against it, the precise meaning of virtual/override/sealed override, constructor chaining with base(...), and — critically — how to recognize when an inheritance hierarchy has become a liability instead of an asset.
Deep Hierarchies in Real Codebases
In a textbook example, a hierarchy is two or three levels: Animal → Dog. In production systems, hierarchies grow because each new requirement looks, at the time, like "just one more specialization":
- Shared:
Authorize(),Capture(), logging
- Adds card-specific validation
- Adds subscription billing on top of card handling
- Adds trial-period logic on top of recurring billing on top of card handling on top of the base
Each individual step made local sense: "we already have RecurringCardPaymentHandler, trials are basically recurring billing with a delay, let's derive from it." Four levels in, changing anything at Level 0 or Level 1 now requires reasoning about every class beneath it — and the person adding Level 3 may not have fully understood Levels 0–2. This is how hierarchies become liabilities without anyone making an obviously bad decision at any single step.
The Fragile Base Class Problem
What It Actually Is
The fragile base class problem is this: a base class can appear completely correct and well-tested in isolation, yet a small, reasonable-looking change to it can break derived classes — not because the derived classes did anything wrong, but because they made assumptions about the base class's internal behavior, not just its public contract.
This happens in two common shapes:
- Behavioral fragility — a base method's implementation changes (like the retry example above), and every subclass that calls
base.Method()inherits the new behavior whether or not it's appropriate for them. - Overload/signature fragility — adding a new overload or virtual method to the base class can change which method a derived class's existing call resolves to, or create ambiguity, without a single line in the derived class being edited.
Why C# Has Tools Against It
C# gives you several deliberate levers to control exactly how much a derived class can assume about, or interfere with, a base class's internals — precisely because the language designers know inheritance is dangerous once a hierarchy has multiple independent maintainers:
virtual— an explicit, deliberate invitation: "this method is designed to be overridden; I've thought about what happens if you do."- non-virtual (the default) — "this method is not part of the extension contract; don't assume you can change its behavior by overriding it, because you can't."
protected— members visible to subclasses but not the outside world, letting a base class expose an "extension surface" narrower than its full implementation.sealed override— lets a class in the middle of a hierarchy say "this particular override is final; no further subclass may change it again," closing off fragility at a specific point (covered in depth in lesson 076).
virtual / override / sealed override — Precisely
public class ReportExporter
{
public virtual string GetFileExtension() => "txt";
}
- Declares a default implementation and explicitly opts into being replaced by subclasses
public class CsvReportExporter : ReportExporter
{
public override string GetFileExtension() => "csv";
}
- Must match the base signature exactly; the compiler enforces this, so the "contract" can't silently drift
public class LockedCsvExporter : CsvReportExporter
{
public sealed override string GetFileExtension() => "csv";
// Any class deriving from LockedCsvExporter cannot override GetFileExtension() again.
}
- Stops the fragility from propagating further down an already-deep chain — a deliberate circuit breaker
Constructor Chaining with base(...)
Construction in a hierarchy always runs base-first, derived-second — this is not optional and can't be reordered. Every derived constructor either implicitly or explicitly calls a base constructor before its own body runs:
public class ExternalServiceClient
{
protected readonly HttpClient Http;
protected readonly string BaseUrl;
protected ExternalServiceClient(HttpClient http, string baseUrl)
{
Http = http ?? throw new ArgumentNullException(nameof(http));
BaseUrl = baseUrl;
}
}
public class InventoryServiceClient : ExternalServiceClient
{
private readonly string _apiKey;
public InventoryServiceClient(HttpClient http, string baseUrl, string apiKey)
: base(http, baseUrl) // base constructor runs FIRST
{
_apiKey = apiKey; // then this body runs
}
}
This matters for real-world design: any invariant the base constructor establishes (like Http never being null) is guaranteed to hold by the time the derived constructor body runs — you can rely on it without re-checking. It also means a base class with a required parameter forces every derived class, forever, to thread that parameter through its own constructor — one more way base class decisions ripple outward.
Real-World Example — Designing a Hierarchy Defensively
Here's an order-fulfillment hierarchy written the way an experienced team would design it: virtual only where extension is truly intended, protected members instead of exposing internals, and small, well-defined extension points instead of one giant overridable method.
public abstract class ShipmentProcessor
{
protected readonly ILogger Logger;
protected ShipmentProcessor(ILogger logger) => Logger = logger;
// Non-virtual: the overall algorithm is NOT open to change.
// This is the "template method" pattern — the shape is fixed,
// only specific steps are extension points.
public void Process(Shipment shipment)
{
Validate(shipment);
var cost = CalculateShippingCost(shipment); // extension point
Logger.Log($"Shipping cost: {cost:C}");
Dispatch(shipment, cost);
}
protected virtual void Validate(Shipment shipment)
{
if (shipment.Weight <= 0)
throw new ArgumentException("Shipment weight must be positive.");
}
// Each concrete carrier MUST provide its own pricing — no sane default exists.
protected abstract decimal CalculateShippingCost(Shipment shipment);
protected virtual void Dispatch(Shipment shipment, decimal cost) =>
Logger.Log($"Dispatched shipment {shipment.Id} for {cost:C}");
}
public sealed class StandardCarrierProcessor : ShipmentProcessor
{
public StandardCarrierProcessor(ILogger logger) : base(logger) { }
protected override decimal CalculateShippingCost(Shipment shipment) =>
shipment.Weight * 2.50m;
}
public sealed class ExpressCarrierProcessor : ShipmentProcessor
{
public ExpressCarrierProcessor(ILogger logger) : base(logger) { }
protected override void Validate(Shipment shipment)
{
base.Validate(shipment); // reuse the base rule...
if (shipment.Weight > 50)
throw new ArgumentException("Express shipping is not available above 50kg.");
}
protected override decimal CalculateShippingCost(Shipment shipment) =>
shipment.Weight * 6.00m + 15m; // rush surcharge
}
Why this design resists the fragile base class problem:
Process()— the overall algorithm — is not virtual. No subclass can silently change the order of validation, pricing, and dispatch. This is deliberate: the base class author decided this shape is not negotiable.- The extension points (
Validate,CalculateShippingCost,Dispatch) are small, focused, and individually documented by their signature — a subclass author can reason about exactly what they're changing. - Both concrete classes are
sealed— the hierarchy is deliberately capped at two levels, so nobody adds a Level 3 "just this once" under deadline pressure. - If
Process()'s internals change later (say, adding a metrics call), every subclass's behavior for validation/pricing/dispatch stays identical — because those are the parts subclasses actually own.
Under the Hood
- A virtual method gets exactly one slot in the object's method table, regardless of how many levels deep the hierarchy goes
- Each level that overrides it replaces that same slot's entry for objects of that level and below
- Even if
Level0.SomeMethod()calls a virtual method internally, and the object is actually aLevel3instance,Level3's override runs — notLevel0's - This is exactly why the retry example in the hook silently affected nine subclasses: their overrides were still what ran, but the code path that reached them changed
- Calling
base.Validate(shipment)does not go through the vtable — it's resolved at compile time to that exact method in the immediate base class - This is the one place where inheritance intentionally breaks polymorphism, by design — otherwise
base.Method()could recurse into itself
- A method with no
virtual/overrideis bound based on the compile-time (static) type of the reference, not the runtime type of the object - This is why hiding a base method with
newis dangerous: which implementation runs depends on the variable's declared type, not the object's actual type — a frequent source of subtle bugs
Common Confusion
"Deep hierarchies are always bad" — not quite
Deep hierarchies aren't inherently wrong; uncontrolled depth is the problem. A framework like ASP.NET Core's ControllerBase family, or the .NET exception hierarchy (Exception → SystemException → InvalidOperationException), has real depth and works fine — because each level adds a genuinely stable, well-documented contract, changes rarely, and is designed by people thinking about every downstream consumer. The difference is intentional design versus organic accretion under deadline pressure.
protected members: extension surface, not a loophole
It's tempting to mark something protected just to "let subclasses reach it if they need to." Every protected member is a promise: this is part of the extension contract now, and changing it is a breaking change for every subclass, exactly like changing a public member is a breaking change for every caller. Treat protected with the same seriousness as public — it's just a smaller, subclass-only audience.
Common Mistakes
Mistake 1 — Making everything virtual "just in case"
Marking every method virtual by default turns the entire class into an extension surface, whether or not that was intended. Every one of those methods is now a place a future change can break a subclass in a way you can't predict.
Mark a method virtual only when you've deliberately decided "subclasses should be able to change this," and you've thought through what happens if they do.
Mistake 2 — Overriding a method and forgetting to call base when the base behavior was load-bearing
protected override void Validate(Shipment shipment)
{
// Forgot base.Validate(shipment) — the "weight must be positive" check is silently gone
if (shipment.Weight > 50)
throw new ArgumentException("Too heavy for express.");
}
Decide explicitly whether an override should extend or replace the base behavior, and document which one it is — usually by calling base.Method() when extending.
Mistake 3 — Deriving "just to reuse a couple of methods"
TrialRecurringCardPaymentHandler : RecurringCardPaymentHandler purely because it happened to have the methods needed, with no genuine "is-a" relationship — this is how the four-level hierarchy in the Big Picture section happened.
Ask whether the relationship is really "is-a." If it's "needs some of the same behavior," reach for composition (lesson 073) instead — inject the shared logic rather than inheriting it.
Mistake 4 — Changing a base class's internal implementation without checking who overrides it
Assuming a base class's private implementation details are free to change because "it's the same public API." If subclasses call base.Method() and depend on its side effects (like the retry example), the behavior contract is broader than the public signature suggests.
Before changing a widely-derived base class's behavior, search for every override that calls base, and treat the change with the same care as a public API change.
When a Hierarchy Has Become a Liability
Watch for these signals — they're strong indicators that inheritance is costing more than it's saving:
- You can't change the base class without reading every subclass first. That's the fragile base class problem manifesting directly — the hierarchy has become too tightly coupled to change safely.
- A subclass overrides a method just to make it do nothing (an empty override, or one that throws
NotSupportedException). This is a Liskov Substitution violation (covered in lesson 074) — a sign the "is-a" relationship was never quite true. - You're four or more levels deep and new team members can't explain what each level adds without reading all of them.
- Multiple, unrelated dimensions of variation are being modeled in one hierarchy — e.g. "payment method" and "billing frequency" both being expressed through subclassing, which forces a combinatorial explosion of classes (
CardOneTime,CardRecurring,BankOneTime,BankRecurring...). This is a strong signal to switch to composition for at least one of the dimensions.
Mental Model
protected = "This is public, just to a smaller audience — subclasses, forever."
sealed override = "This extension point is now closed, permanently, from here down."
Remember:
· Every override that calls
base is coupled to the base's behavior, not just its signature.· A change to a widely-derived base class is a change to every subclass's behavior, whether or not their code changed.
· Depth isn't the enemy — uncontrolled, undocumented depth is.
Key Takeaway
- The fragile base class problem: a base class change can break subclasses that never touched their own code, because subclasses depend on the base's behavior, not just its signature.
- Mark methods
virtualdeliberately, not by default — every virtual method is a promise about future extensibility. protectedmembers are part of your public contract with subclasses; changing them is a breaking change.sealed overridelets you deliberately stop a hierarchy from growing further at a specific point.- Base constructors always run before derived constructor bodies —
base(...)lets a derived class satisfy that contract explicitly. - A hierarchy becomes a liability when you can't safely change the base without auditing every subclass, or when it's modeling more than one independent axis of variation.
Check Your Understanding
You've seen how inheritance behaves once it's deep, shared, and long-lived. Let's test the reasoning.
1. A base class method's internal implementation is changed (its signature stays identical), and several subclasses that call base.Method() start behaving differently, even though none of their own code was edited. What is this an example of?
Show answer
Correct: B
Why B is correct: This is exactly the fragile base class problem — a base class change that doesn't touch the public signature can still ripple into every subclass whose overrides call into that behavior via base.Method().
Why A is incorrect: This is expected (if unwanted) behavior of virtual dispatch and base calls — not a compiler defect.
Why C is incorrect: It has everything to do with inheritance — it's a direct consequence of how base.Method() couples a subclass to the base's implementation.
Why D is incorrect: Liskov Substitution is about whether a subclass can be safely substituted for its base type from a caller's perspective — this scenario is about a base class change affecting subclasses, a related but distinct issue covered in lesson 074.
Reinforcement: Any override that calls base is coupled to the base's behavior, not just its signature.
2. What does sealed override accomplish that plain override does not?
Show answer
Correct: C
Why C is correct: sealed override provides the override for the current class and explicitly closes off that particular virtual member from being overridden any further down the hierarchy — a deliberate circuit breaker against unbounded extension.
Why A is incorrect: Sealing has nothing to do with making a member static; the member is still an instance member and still participates in virtual dispatch for callers above it in the hierarchy.
Why B is incorrect: A sealed override can still be called with base.Method() from within a further subclass — it just can't be re-overridden.
Why D is incorrect: Accessibility (public/protected/etc.) is unrelated to sealing; a sealed override keeps whatever accessibility it had.
Reinforcement: Use sealed override when you want to deliberately stop a hierarchy from growing further at a specific extension point.
3. In a derived class constructor, when does the base class constructor run relative to the derived constructor's own body?
Show answer
Correct: C
Why C is correct: The base constructor (whether called implicitly or via an explicit base(...)) always completes before the derived constructor's own body runs — this order is guaranteed by the language and can't be changed.
Why A is incorrect: This would allow the derived constructor to use uninitialized base state, which C# specifically prevents by running base-first.
Why B is incorrect: Constructors run sequentially, not interleaved — base-first, then derived.
Why D is incorrect: If no base(...) is written, the compiler implicitly calls the base class's parameterless constructor (if one exists) — the base constructor still runs; it's just not written explicitly.
Reinforcement: This ordering guarantee is exactly why a derived constructor's body can safely rely on any invariant the base constructor establishes.
4. Which of the following is the strongest signal that an inheritance hierarchy has become a maintenance liability?
Show answer
Correct: B
Why B is correct: A subclass that has to disable or reject inherited behavior is a strong sign the "is-a" relationship was never really true — this is a Liskov Substitution violation and one of the clearest warning signs that inheritance is the wrong tool here.
Why A is incorrect: A shallow, well-documented hierarchy is generally healthy — this describes good design, not a liability.
Why C is incorrect: Constructor parameter count is unrelated to whether a hierarchy is well-designed.
Why D is incorrect: Virtual methods are a normal, expected part of a deliberately-designed extensible hierarchy — their presence alone says nothing about liability.
Reinforcement: An override that exists only to refuse inherited behavior is one of the clearest signals to reach for composition instead.
You can now reason about inheritance the way a senior engineer reviewing a pull request would — not just "does it compile" but "what does this cost the next person who touches the base class?"