← Open in the full interactive course (progress tracking, search & more)

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":

A HIERARCHY THAT GREW ORGANICALLY OVER THREE YEARS
LEVEL 0 — PaymentHandler base, year 1
LEVEL 1 — CardPaymentHandler year 1
LEVEL 2 — RecurringCardPaymentHandler year 2
LEVEL 3 — TrialRecurringCardPaymentHandler year 3, added under deadline pressure

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:

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 / override / sealed override — Precisely

THE THREE-WAY CONTRACT
1. virtual — "I MAY BE OVERRIDDEN"
public class ReportExporter
{
    public virtual string GetFileExtension() => "txt";
}
2. override — "I AM REPLACING THAT DEFAULT"
public class CsvReportExporter : ReportExporter
{
    public override string GetFileExtension() => "csv";
}
3. sealed override — "AND NO FURTHER SUBCLASS MAY CHANGE THIS AGAIN"
public class LockedCsvExporter : CsvReportExporter
{
    public sealed override string GetFileExtension() => "csv";
    // Any class deriving from LockedCsvExporter cannot override GetFileExtension() again.
}

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:

Under the Hood

DISPATCH ACROSS A DEEP HIERARCHY
1. ONE VTABLE SLOT PER VIRTUAL METHOD, NOT PER CLASS
2. THE RUNTIME ALWAYS USES THE MOST-DERIVED OVERRIDE
3. base.Method() IS A DIRECT, NON-VIRTUAL CALL
4. NON-VIRTUAL METHODS ARE RESOLVED AT COMPILE TIME

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:

Rule of thumb: inheritance is a good fit for a stable, narrow "is-a" relationship with a small, deliberate extension surface. The moment you're modeling more than one independent axis of variation, or the hierarchy needs a chart to explain, it's time to look at composition (lesson 073) instead.

Mental Model

virtual = "I'm offering this as an extension point, deliberately."
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


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?"


dotnetmadeeasy.com — Learn C# and .NET, the right way.