Composition Over Inheritance: Refactoring Real C# Code
Inheritance chains break the Liskov Substitution Principle in practice. Here is how to refactor a 3-level hierarchy into composable behaviour with C# interfaces and DI.
- Author
- Randhir Jassal
- Published
- Reading time
- 8 min read
The problem with inheritance hierarchies
A common evolution: you start with Notification, add EmailNotification, then SmsNotification, then PushNotification. Soon you need an SMS that also pushes — and inheritance forces a diamond.
// The trap
public abstract class Notification {
public abstract Task SendAsync(string to, string body);
}
public class EmailNotification : Notification { /* ... */ }
public class SmsNotification : Notification { /* ... */ }
// Now: PushNotification that also logs to email?
You either duplicate code, add awkward base-class flags, or override with NotImplementedException. All bad.
Composition: behaviour as injectable strategies
Decompose the verb. Notification is not a hierarchy — it is a pipeline of channels.
public interface IChannel {
Task DeliverAsync(Recipient to, Message msg, CancellationToken ct);
}
public sealed class EmailChannel(IEmailGateway gw) : IChannel {
public Task DeliverAsync(Recipient to, Message msg, CancellationToken ct)
=> gw.SendAsync(to.Email!, msg.Subject, msg.Body, ct);
}
public sealed class SmsChannel(ISmsGateway gw) : IChannel { /* ... */ }
public sealed class PushChannel(IPushGateway gw) : IChannel { /* ... */ }
public sealed class NotificationService(IEnumerable<IChannel> channels) {
public async Task NotifyAsync(Recipient to, Message msg, CancellationToken ct) {
var tasks = channels.Select(c => c.DeliverAsync(to, msg, ct));
await Task.WhenAll(tasks);
}
}
DI registration is where the policy lives — not in a class hierarchy.
services.AddScoped<IChannel, EmailChannel>();
services.AddScoped<IChannel, SmsChannel>();
services.AddScoped<IChannel, PushChannel>();
services.AddScoped<NotificationService>();
Adding a new channel is a one-line registration. Removing one is a one-line deletion.
Decorator: cross-cutting concerns without inheritance
Need to log every channel send? Don't add an abstract base. Wrap with a decorator.
public sealed class LoggingChannel(IChannel inner, ILogger<LoggingChannel> log) : IChannel {
public async Task DeliverAsync(Recipient to, Message msg, CancellationToken ct) {
var sw = Stopwatch.StartNew();
try {
await inner.DeliverAsync(to, msg, ct);
log.LogInformation("delivered {Channel} in {Ms}ms", inner.GetType().Name, sw.ElapsedMilliseconds);
} catch (Exception ex) {
log.LogError(ex, "delivery failed via {Channel}", inner.GetType().Name);
throw;
}
}
}
Apply selectively per channel — no forced base-class.
When inheritance still earns its keep
- True is-a relationships in a closed domain (Shape → Circle, Rectangle)
- Framework template methods (
ControllerBase,BackgroundService) - Two levels max — never three
The rule I follow: if a base class has more than 30 lines or three abstract methods, it's secretly trying to be three classes. Refactor.
Get the next issue
A short, curated email with the newest posts and questions.