Composition over Inheritance: Fragile Base Class, Delegation and Pluggable Behavior
Inheritance ties a subclass to its base class's implementation details, so a harmless change in the base class can silently break a subclass, while composition and delegation depend only on the contract. The fragile base class problem shown with real output, the class explosion when behavior grows along two axes, and where inheritance is right: Collection<T> hooks and sealed.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
An in-house platform team writes the Bookstore's stock records as a shared library: StockLedger. To write every incoming book to an audit report, the warehouse team derives from this class and overrides two methods, one for a single line and one for bulk receiving:
AuditedStockLedger ledger = new();
ledger.Receive("978-0441013593", 2);
ledger.ReceiveRange([("978-0141439518", 3), ("978-0547928227", 5)]);
Console.WriteLine($"Received units: {ledger.ReceivedUnits}");
Console.WriteLine($"Emma in stock: {ledger.QuantityOf("978-0141439518")}");
// The base class written by the platform team (v1)
public class StockLedger
{
private readonly Dictionary<string, int> _stock = [];
public virtual void Receive(string isbn, int quantity) =>
_stock[isbn] = _stock.GetValueOrDefault(isbn) + quantity;
public virtual void ReceiveRange(IEnumerable<(string Isbn, int Quantity)> lines)
{
foreach ((string isbn, int quantity) in lines)
Receive(isbn, quantity);
}
public int QuantityOf(string isbn) => _stock.GetValueOrDefault(isbn);
}
// The class the warehouse team derived for auditing
public class AuditedStockLedger : StockLedger
{
public int ReceivedUnits { get; private set; }
public override void Receive(string isbn, int quantity)
{
ReceivedUnits += quantity;
base.Receive(isbn, quantity);
}
public override void ReceiveRange(IEnumerable<(string Isbn, int Quantity)> lines)
{
List<(string Isbn, int Quantity)> list = [.. lines];
ReceivedUnits += list.Sum(l => l.Quantity);
base.ReceiveRange(list);
}
}
One line brought 2 books, the bulk receipt brought 3 and 5. What does ReceivedUnits print?
Fragile Base Class: The Part Nobody Touched
The output:
Received units: 18
Emma in stock: 3
The stock is correct, the audit count is wrong: 18 instead of 10. The cause hides inside the base class. StockLedger.ReceiveRange does its work by calling Receive for each line; because Receive is virtual, the call goes to the subclass's override, and the 8 units in the bulk receipt are counted once in ReceiveRange and again in Receive. That fact is not part of the base class's public contract; it is an implementation detail. In Effective Java, Joshua Bloch (designer of the Java Collections framework) explains "Favor composition over inheritance" with a counter derived from Java's HashSet that shows exactly the same bug; different language, same mechanism.
The warehouse team finds the bug and "fixes" it: they delete the ReceiveRange override, since the base class already calls Receive. The tests pass, it prints 10. A few months later the platform team gets complaints about slow bulk receipts and speeds ReceiveRange up by writing straight to the dictionary without the virtual call. The release notes say "no behavior change", and for anyone looking from outside, there really is none:
AuditedStockLedger ledger = new();
ledger.Receive("978-0441013593", 2);
ledger.ReceiveRange([("978-0141439518", 3), ("978-0547928227", 5)]);
Console.WriteLine($"Received units: {ledger.ReceivedUnits}");
Console.WriteLine($"Emma in stock: {ledger.QuantityOf("978-0141439518")}");
// v2: the platform team sped up ReceiveRange; it no longer calls Receive
public class StockLedger
{
private readonly Dictionary<string, int> _stock = [];
public virtual void Receive(string isbn, int quantity) =>
_stock[isbn] = _stock.GetValueOrDefault(isbn) + quantity;
public virtual void ReceiveRange(IEnumerable<(string Isbn, int Quantity)> lines)
{
foreach ((string isbn, int quantity) in lines)
_stock[isbn] = _stock.GetValueOrDefault(isbn) + quantity;
}
public int QuantityOf(string isbn) => _stock.GetValueOrDefault(isbn);
}
// The warehouse team's "fix": the ReceiveRange override was removed, relying on Receive
public class AuditedStockLedger : StockLedger
{
public int ReceivedUnits { get; private set; }
public override void Receive(string isbn, int quantity)
{
ReceivedUnits += quantity;
base.Receive(isbn, quantity);
}
}
Received units: 2
Emma in stock: 3
Not a single line of the warehouse team's code changed, and the audit report now misses all 8 units. This is called the fragile base class problem; the term became established with the paper Mikhajlov and Sekerinski presented at ECOOP (the European Conference on Object-Oriented Programming) in 1998. The base class author changed an implementation detail of their own class, the change broke no contract, and yet a subclass broke. The subclass depended not only on the base class's public face but also on how its methods call each other (self-use). That dependency is written down nowhere, the compiler cannot see it, and tests catch it only if they try the right scenario.
The Two Principles of Design Patterns
In 1994 Gamma, Helm, Johnson and Vlissides (the four authors known as GoF, the Gang of Four) set out two principles in the introduction to Design Patterns, and much of the rest of the book applies them. The first: program to an interface, not an implementation. Bind an object to a variable, parameter or field through the contract it offers rather than its concrete class. The second: favor object composition over class inheritance.
The book distinguishes two kinds of reuse. It calls reuse through inheritance white-box reuse, because the inside of the base class is visible to the subclass, and reuse through composition black-box reuse, because the parts know each other only through their interfaces. The same chapter includes an often-repeated line: because inheritance exposes a subclass to the details of its parent's implementation, inheritance breaks encapsulation. The 18 and the 2 above are exactly that. Inheritance is fixed at compile time and cannot change at run time; with composition you can choose, swap and reorder the parts at run time.
This is not a ban on inheritance but a default. The book itself uses inheritance; it just asks that it not be the first reflex.
Composition and Delegation
Let's build the same audit without inheritance. First we pull the contract out into an interface, then the audit class implements that interface and hands the real work to the ledger it holds:
AuditedStockLedger ledger = new(new StockLedger());
ledger.Receive("978-0441013593", 2);
ledger.ReceiveRange([("978-0141439518", 3), ("978-0547928227", 5)]);
Console.WriteLine($"Received units: {ledger.ReceivedUnits}");
Console.WriteLine($"Emma in stock: {ledger.QuantityOf("978-0141439518")}");
public interface IStockLedger
{
void Receive(string isbn, int quantity);
void ReceiveRange(IEnumerable<(string Isbn, int Quantity)> lines);
int QuantityOf(string isbn);
}
public sealed class StockLedger : IStockLedger
{
private readonly Dictionary<string, int> _stock = [];
public void Receive(string isbn, int quantity) =>
_stock[isbn] = _stock.GetValueOrDefault(isbn) + quantity;
public void ReceiveRange(IEnumerable<(string Isbn, int Quantity)> lines)
{
foreach ((string isbn, int quantity) in lines)
_stock[isbn] = _stock.GetValueOrDefault(isbn) + quantity;
}
public int QuantityOf(string isbn) => _stock.GetValueOrDefault(isbn);
}
// No inheritance: auditing hands the work to the ledger it holds (delegation)
public sealed class AuditedStockLedger(IStockLedger inner) : IStockLedger
{
public int ReceivedUnits { get; private set; }
public void Receive(string isbn, int quantity)
{
ReceivedUnits += quantity;
inner.Receive(isbn, quantity);
}
public void ReceiveRange(IEnumerable<(string Isbn, int Quantity)> lines)
{
List<(string Isbn, int Quantity)> list = [.. lines];
ReceivedUnits += list.Sum(l => l.Quantity);
inner.ReceiveRange(list);
}
public int QuantityOf(string isbn) => inner.QuantityOf(isbn);
}
Received units: 10
Emma in stock: 3
This uses the fast v2 implementation of StockLedger, and the result is correct. More importantly, it will stay correct no matter what changes inside StockLedger: AuditedStockLedger relies only on the promise the interface makes, that each call arrives once. Whether inner.ReceiveRange calls Receive internally does not concern it, because those calls go to inner's own methods and never come back to the audit class. We also made StockLedger sealed; deriving from it and depending on its self-use is no longer possible.
The technique is called delegation: an object forwards the calls it receives to another object it holds. Its cost is visible too: you have to write and forward methods like QuantityOf that add nothing. C# has no shortcut for this; the language has no class delegation support like Kotlin's by keyword. The boilerplate grows as the interface grows, which is one more reason to keep interfaces small (the role interfaces we discussed in the ISP (Interface Segregation Principle) post).
This structure, implementing the same interface and adding behavior in between, is the essence of the Decorator pattern, which the upcoming Design Patterns series covers separately. The Stream family is its best-known .NET example: GZipStream and BufferedStream take another Stream in their constructors and wrap it; the DelegatingHandler chain of HttpClient is built the same way, with each handler passing the work on to the next.
Behavior That Grows Along Two Axes
Inheritance's second big problem shows up when behavior varies along more than one axis. The Bookstore's notifications have two channels: email and SMS (Short Message Service). Three behaviors are wanted as well: retry on transient errors, don't send between 22:00 and 08:00, and log every send. With inheritance, every combination is a subclass:
public abstract class Notifier
{
public abstract void Send(string to, string message);
}
public class EmailNotifier : Notifier
{
public override void Send(string to, string message) => Console.WriteLine($"[email] {to}: {message}");
}
public class SmsNotifier : Notifier
{
public override void Send(string to, string message) => Console.WriteLine($"[sms] {to}: {message}");
}
// Every behavior × every channel = a separate subclass
public class RetryingEmailNotifier : EmailNotifier
{
public override void Send(string to, string message)
{
for (int attempt = 1; ; attempt++)
{
try { base.Send(to, message); return; }
catch (IOException) when (attempt < 3) { }
}
}
}
public class QuietHoursEmailNotifier : EmailNotifier
{
public override void Send(string to, string message)
{
if (DateTime.Now.Hour is >= 22 or < 8) { Console.WriteLine($"[deferred] {to}"); return; }
base.Send(to, message);
}
}
public class LoggingEmailNotifier : EmailNotifier
{
public override void Send(string to, string message)
{
Console.WriteLine($"[log] sending to {to}");
base.Send(to, message);
}
}
// The same three classes for SMS, with the same bodies, once more
The full file has two channel classes and 2 × 3 = 6 behavior subclasses. The retry loop, the quiet-hours check and the log line are each written twice. And as soon as you say "retry and also don't send at night" you need a seventh class; since C# does not allow multiple class inheritance, you cannot derive it from the two existing classes, so you copy the body once more. When the push channel arrives, three more subclasses. The same behaviors with composition:
TimeProvider evening = new FixedTimeProvider(new DateTimeOffset(2026, 11, 27, 23, 30, 0, TimeSpan.Zero));
TimeProvider morning = new FixedTimeProvider(new DateTimeOffset(2026, 11, 28, 10, 0, 0, TimeSpan.Zero));
// The parts are stacked in the order we want
INotificationChannel email = new LoggingChannel(new RetryingChannel(new EmailChannel(), maxAttempts: 3));
INotificationChannel smsAtNight = new QuietHoursChannel(new SmsChannel(failuresBeforeSuccess: 0), evening);
INotificationChannel smsInTheMorning =
new QuietHoursChannel(new RetryingChannel(new SmsChannel(failuresBeforeSuccess: 1), maxAttempts: 3), morning);
email.Send("ada@example.com", "Order 1001 confirmed");
smsAtNight.Send("+905550000002", "Order 1002 confirmed");
smsInTheMorning.Send("+905550000002", "Order 1002 confirmed");
public interface INotificationChannel
{
void Send(string to, string message);
}
public sealed class EmailChannel : INotificationChannel
{
public void Send(string to, string message) => Console.WriteLine($"[email] {to}: {message}");
}
public sealed class SmsChannel(int failuresBeforeSuccess) : INotificationChannel
{
private int _failures;
public void Send(string to, string message)
{
// Simulate a transient failure at the real SMS provider
if (_failures++ < failuresBeforeSuccess)
throw new IOException("SMS gateway timeout");
Console.WriteLine($"[sms] {to}: {message}");
}
}
public sealed class RetryingChannel(INotificationChannel inner, int maxAttempts) : INotificationChannel
{
public void Send(string to, string message)
{
for (int attempt = 1; ; attempt++)
{
try
{
inner.Send(to, message);
return;
}
catch (IOException ex) when (attempt < maxAttempts)
{
Console.WriteLine($"[retry] attempt {attempt} failed: {ex.Message}");
}
}
}
}
public sealed class QuietHoursChannel(INotificationChannel inner, TimeProvider clock) : INotificationChannel
{
public void Send(string to, string message)
{
int hour = clock.GetLocalNow().Hour;
if (hour is >= 22 or < 8)
{
Console.WriteLine($"[deferred] {to}: queued until 08:00");
return;
}
inner.Send(to, message);
}
}
public sealed class LoggingChannel(INotificationChannel inner) : INotificationChannel
{
public void Send(string to, string message)
{
Console.WriteLine($"[log] sending to {to}");
inner.Send(to, message);
}
}
public sealed class FixedTimeProvider(DateTimeOffset now) : TimeProvider
{
public override DateTimeOffset GetUtcNow() => now;
public override TimeZoneInfo LocalTimeZone => TimeZoneInfo.Utc;
}
[log] sending to ada@example.com
[email] ada@example.com: Order 1001 confirmed
[deferred] +905550000002: queued until 08:00
[retry] attempt 1 failed: SMS gateway timeout
[sms] +905550000002: Order 1002 confirmed
Two channels and three behaviors, five parts in total. All six combinations, and stacked versions of them, are built from these five parts; in the morning SMS the quiet-hours check, the retry and the channel all ran together. The push channel means one class, and the three existing behaviors plug onto it without any change. The quiet-hours check no longer looks at DateTime.Now but at a TimeProvider passed in from outside, so the night and morning scenarios in the output can be repeated in tests with a fixed time. The decision about which parts are stacked in which order belongs to the composition root we discussed in the previous post.
When Is Inheritance Right?
In three situations inheritance is still the best tool. The first is a real is-a relationship: the subclass can stand in for the base class in every context, meaning it satisfies the behavioral subtyping conditions from this series' LSP (Liskov Substitution Principle) post. The second is a base class designed for inheritance. A nice .NET example is System.Collections.ObjectModel.Collection<T>. In the C# OOP (Object-Oriented Programming) series post that introduced SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion), "Composition in C# and an Introduction to SOLID", we saw a cart derived from List<T> whose rule was bypassed through IList and AddRange, because List<T>'s methods are not virtual. Collection<T>, on the other hand, offers four protected virtual hooks for exactly this purpose: InsertItem, SetItem, RemoveItem, ClearItems. Every public way of adding goes through InsertItem:
using System.Collections.ObjectModel;
LimitedCart cart = new(maxItems: 3);
cart.Add(new OrderItem("978-0441013593", 1));
cart.Add(new OrderItem("978-0141439518", 1));
cart.Insert(0, new OrderItem("978-0547928227", 1));
IList<OrderItem> list = cart;
TryAdd(() => list.Add(new OrderItem("978-0061120084", 1)));
TryAdd(() => cart.Insert(1, new OrderItem("978-0451524935", 1)));
Console.WriteLine(cart.Count);
static void TryAdd(Action add)
{
try { add(); }
catch (InvalidOperationException ex) { Console.WriteLine(ex.Message); }
}
public sealed record OrderItem(string Isbn, int Quantity);
// Collection<T> is designed for inheritance: every insertion goes through InsertItem
public sealed class LimitedCart(int maxItems) : Collection<OrderItem>
{
protected override void InsertItem(int index, OrderItem item)
{
if (Count >= maxItems)
throw new InvalidOperationException($"A cart can hold at most {maxItems} items.");
base.InsertItem(index, item);
}
}
A cart can hold at most 3 items.
A cart can hold at most 3 items.
3
Add, Insert and the call through IList all went through the same door; the rule held. ObservableCollection<T> also derives from Collection<T> by overriding the same hooks. The difference is that the base class author made it part of the contract which method calls which: the self-use is documented and fixed. The Framework Design Guidelines (Microsoft's .NET library design rules) recommend the same thing: prefer protected over public for virtual members, and have public members offer extensibility by calling a protected virtual member. BackgroundService.ExecuteAsync and ASP.NET Core's ControllerBase are in the same category: the framework has opened a place to call you.
The third is a hierarchy closed to further derivation. The most honest way to say a class was not designed for inheritance is to make it sealed. The CA1852 analyzer, introduced in .NET 7, flags classes that are not visible outside the assembly and have no subtypes inside it; according to its documentation, sealed classes can also improve performance. The rule is off by default; enable it in .editorconfig:
Console.WriteLine(new ShippingLabel("978-0441013593").Print());
Console.WriteLine(new GiftLabel("978-0141439518", "Happy birthday").Print());
class ShippingLabel(string isbn)
{
public virtual string Print() => $"LABEL {isbn}";
}
class GiftLabel(string isbn, string note) : ShippingLabel(isbn)
{
public override string Print() => $"{base.Print()} / {note}";
}
Program.cs(9,7): warning CA1852: Type 'GiftLabel' can be sealed because it has no subtypes in its containing assembly and is not externally visible
The analyzer did not flag ShippingLabel, because a class derives from it; it did flag GiftLabel. Making sealed the default for new classes and opening them when needed is cheaper than the reverse: removing sealed breaks no code, adding it breaks everyone who derived.
Common Pitfalls
Creating a base class to share code. Writing a BaseService because two classes share three methods ties both to the same change schedule if there is no is-a relationship between them. Putting the shared code in a separate class that both use avoids the same duplication and keeps the coupling loose. This is the class-level version of the previous post's wrong abstraction.
Protected fields. Exposing the base class's state directly to subclasses removes encapsulation along the whole inheritance hierarchy. If the base class wants to protect an invariant, subclasses being able to write to that field makes that promise impossible.
Deep hierarchies. Understanding the behavior of a class four or five levels down requires reading the whole chain. In .NET the CA1501 analyzer exists to flag excessively deep inheritance; the fragile base class risk multiplies as depth grows.
Overdoing composition too. In a chain of ten wrappers, finding where a call goes takes ten steps in the debugger. Delegation boilerplate becomes tiresome with large interfaces. Composition is a default, not the answer to every design question.
Labeling inheritance simply as "bad". Deliberately designed base classes such as Collection<T>, Stream and BackgroundService are the backbone of frameworks. The problem is not inheritance but deriving from a class that was not designed for it.
A Small Experiment
★☆☆ Change the opening AuditedStockLedger so that it does not override ReceiveRange and run it against the v1 base class. Then copy in the v2 base class. Write down in a table which number you see with which version.
★★☆ Add a PushChannel to the composition version and wrap it with the quiet-hours and logging behaviors. How many new classes would the inheritance version have needed for the same thing?
★★★ For now, QuietHoursChannel only prints night-time messages to the screen. Design a version that collects them in a Queue<(string To, string Message)> and sends them in the morning. Who should drain this queue: the channel itself, or a separate background job? Write down which principle you base your decision on.
In the next post we move on to principles that are not quoted as often as SOLID but do at least as much work in everyday decisions: Separation of Concerns, the Principle of Least Astonishment, Fail Fast, Command-Query Separation, and the GRASP (General Responsibility Assignment Software Patterns) list by Craig Larman, known for his book on UML (Unified Modeling Language) and design, Applying UML and Patterns, which asks who should get each responsibility.