Design judgement on composition versus inheritance: is-a versus has-a, Liskov, number of variation axes, framework extension points, and refactoring a broken hierarchy to composition.
Default to composition: build behaviour from small objects behind interfaces and inject them. It keeps coupling low, lets behaviour change at run time and is easy to test. Use inheritance only when all of these hold: there is a true is-a relationship where every subtype can substitute for the base (Liskov), the base was designed for extension (documented virtual hooks, protected constructor), there is one axis of variation, and you want to share state plus a fixed algorithm (template method). Framework extension points such as BackgroundService, DelegatingHandler and DbContext are good inheritance cases. When a hierarchy explodes or subclasses override things to throw NotSupportedException, refactor by extracting the varying behaviour into strategies and composing them.
My default is composition, and I use inheritance only when a set of criteria holds. The question isn't "is-a vs has-a" in English — almost anything can be phrased both ways — it's whether the subtype is truly substitutable, whether the variation is one stable axis, and whether I control the base class. I'd walk through the criteria, a case where inheritance is right, and how I'd refactor a hierarchy gone wrong.
I choose inheritance only if all of these are true:
NotSupportedException, no weaker guarantees.Otherwise I prefer composition: inject collaborators (strategies, policies, decorators), which gives:
Framework extension points designed for it:
public sealed class OutboxPublisher(IServiceScopeFactory scopes, ILogger<OutboxPublisher> log) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(5));
while (await timer.WaitForNextTickAsync(stoppingToken)) { /* publish pending messages */ }
}
}
BackgroundService implements IHostedService's start/stop lifecycle and cancellation once; subclasses supply only ExecuteAsync. Same for DelegatingHandler, JsonConverter<T>, AuthenticationHandler<T>, ControllerBase. These bases are designed for extension: small, documented hooks, the base controls the flow (template method), and subtypes are fully substitutable.
In domain code, a small closed hierarchy of results or events (PaymentResult.Approved/Declined) or a family of value types with shared invariants can also be appropriate — often as sealed records with a private base constructor.
PdfEmailReport, PdfSftpReport, CsvEmailReport...NotSupportedException) — Liskov violations.protected helpers used by some subclasses, with flags like if (this is SpecialImporter).Incrementally, with tests as a safety net:
IReportQuery, IReportFormatter, IReportDelivery), implementing them by moving code out of subclasses.This is the "Replace Inheritance with Delegation" and "Replace Subclass with Strategy" refactorings (Fowler).
[In my case] a reporting service had a hierarchy of [~35] report classes combining format and delivery channel; adding SharePoint delivery meant touching [a dozen] classes. I added snapshot tests for each report's output, extracted formatter and delivery strategies, made the base class delegate to them, then migrated reports to a definition table one by one over [a few sprints]. New channels became one class and a registration, and the base class disappeared.
Tests design judgement, not definitions.
Green flags: clear criteria (is-a, LSP, axes, run-time change, ownership); names good inheritance cases (framework extension points, template method); recognises warning signs; describes a concrete refactor with strategies and decorators; acknowledges composition's wiring cost.
Red flags: "always use inheritance for reuse" or "never use inheritance"; cannot explain LSP; no refactoring story.
From Design Patterns (GoF, 1994): "Favour object composition over class inheritance." Reasons:
Checks I use in reviews:
Classic violations: Square : Rectangle (setting width changes height), read-only collections implementing mutable interfaces, Stream subclasses that don't support Seek (mitigated in .NET by CanSeek).
Inheritance makes adding new types easy; adding new operations hard (touch every subclass). Pattern matching/visitor makes new operations easy and new types hard. Composition with strategies allows independent axes to vary. Identify which changes are frequent in your domain and choose accordingly.
services.AddKeyedScoped<IReportFormatter, PdfFormatter>("pdf"), resolved by key from configuration.Inheritance has persistence costs: TPH tables with many nullable columns, TPT joins, polymorphic JSON requiring [JsonDerivedType] and discriminators. Composition (owned/complex types, separate entities) often maps more simply. When a domain truly needs polymorphism (payment methods with different data), consider one table per type with a shared interface or a discriminated JSON column.
| Situation | Choose |
|---|---|
| Framework base designed for extension | Inheritance |
| Closed set of variants with shared invariants (results, events) | Sealed hierarchy (often records) or pattern matching |
| Multiple independent axes | Composition (strategies) |
| Cross-cutting behaviour | Decorators / middleware |
| Reusing helper code | Composition or static pure functions |
| Behaviour selected by configuration/tenant | Composition with keyed services |
| Third-party class you don't control | Composition (wrap/adapt) |
// Before: inheritance per combination
public class EmailNotifier { public virtual Task SendAsync(Message m) => /* SMTP */ Task.CompletedTask; }
public class RetryingEmailNotifier : EmailNotifier { /* override with retries */ }
public class BatchedRetryingEmailNotifier : RetryingEmailNotifier { /* ... */ }
// ...and the same again for SMS and push
// After: one axis = one interface; policies are decorators
public interface IChannel { Task SendAsync(Message message, CancellationToken ct); }
public sealed class EmailChannel(ISmtpClient smtp) : IChannel
{
public Task SendAsync(Message m, CancellationToken ct) => smtp.SendAsync(m.To, m.Subject, m.Body, ct);
}
public sealed class SmsChannel(ISmsGateway gateway) : IChannel
{
public Task SendAsync(Message m, CancellationToken ct) => gateway.SendAsync(m.To, m.Body, ct);
}
public sealed class RetryingChannel(IChannel inner, ResiliencePipeline pipeline) : IChannel
{
public Task SendAsync(Message m, CancellationToken ct) =>
pipeline.ExecuteAsync(async t => await inner.SendAsync(m, t), ct).AsTask();
}
// Composition root: any combination without new classes
builder.Services.AddKeyedSingleton<IChannel>("email", (sp, _) =>
new RetryingChannel(new EmailChannel(sp.GetRequiredService<ISmtpClient>()),
sp.GetRequiredService<ResiliencePipelineProvider<string>>().GetPipeline("notify")));
// Where inheritance IS right: a framework extension point
public sealed class OutboxDispatcher(IOutbox outbox) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
await outbox.DispatchAsync(stoppingToken);
}
}
What happens: "A SavingsAccount is an Account, so inherit."
Why it is wrong: English phrasing doesn't prove substitutability or a single axis of variation.
Do instead: apply Liskov and the variation criteria.
What happens: BaseService with logging and helpers inherited by all services.
Why it is wrong: unrelated classes coupled to a base; changes ripple widely.
Do instead: inject collaborators or use small static helpers.
What happens: replacing a hierarchy in one large PR without tests. Why it is wrong: behaviour subtly depending on overrides gets lost. Do instead: add characterisation tests and migrate incrementally via delegation.
What happens: framework extension points reimplemented via composition to avoid inheriting. Why it is wrong: fights the framework and duplicates lifecycle logic. Do instead: inherit from bases designed for extension.
What happens: many levels of inheritance with overrides at each. Why it is wrong: hard to understand and fragile. Do instead: keep one or two levels; compose the rest.
NotSupportedExceptionWhat happens: subtypes can't support base operations. Why it is wrong: violates Liskov; callers break. Do instead: split interfaces or redesign the hierarchy.
What happens: subclassing third-party classes to tweak behaviour. Why it is wrong: upgrades break your subclasses. Do instead: wrap them (adapter/decorator).
What happens: new strategy classes left open. Why it is wrong: invites the same inheritance problems again. Do instead: seal by default.
Seniors describe the decision in terms of coupling, axes of variation and substitutability, not slogans.
| Signal | Leaning |
|---|---|
| Framework base class designed for it | Inherit, keep thin, seal |
| One axis, shared fixed algorithm, owned base | Inherit (template method) |
| Behaviour chosen by config or tenant | Compose |
| Cross-cutting concern (retry, cache, log) | Decorator |
| Two or more axes | Compose |
| Need independent tests per behaviour | Compose |
They also mention the cost of composition: more wiring and indirection. A senior keeps the composition root readable (grouped registrations, keyed services, factories) so the flexibility does not turn into a maze.
Composition means a class has other objects as parts and delegates work to them, instead of inheriting behaviour. A Car has an Engine and Wheels; an OrderProcessor has a validator, a pricer and a notifier. In the strict UML sense, composition also means ownership: the parts are created and destroyed with the whole and are not shared. Benefits: behaviour can be swapped (pass a different IPricingStrategy), combined freely (decorators wrap each other), tested in isolation, and changed without a fragile base class. The cost is a little more wiring, usually handled by constructors or dependency injection.
Inheritance lets a derived class reuse and specialise a base class: class SavingsAccount : Account. C# allows one base class but many interfaces. The derived class inherits all members except constructors and finalisers (private ones exist but are not accessible), can call base members with base., and can override virtual/abstract members. Declaring a member with the same name without override hides it (new), and which one runs then depends on the variable's static type, a common source of bugs. Use inheritance only for a real is-a relationship where a derived object can be used anywhere the base is expected (Liskov substitution). Deep hierarchies, inheriting just to reuse code, and fragile base classes are the main problems; composition is often better.
An abstract class is a base class that cannot be instantiated and may declare abstract members with no body, which every non-abstract derived class must override. Unlike an interface it can hold instance fields, constructors and fully implemented members, so it is the place for shared state and a shared algorithm. The classic use is the template method pattern: a public non-virtual method defines the steps and calls protected abstract or protected virtual hooks. Constructors should be protected. A class can derive from only one abstract class. Adding a new virtual member with a body is non-breaking, but adding a new abstract member breaks every derived class.
How to tell it (STAR)
**Situation:** our notification service had [14] classes built by inheritance, and a retry fix had been missed in one copy. **Task:** add push notifications without growing the hierarchy again. **Action:** I identified three axes of variation, made channels implementations of one interface, turned retries, batching and quiet hours into decorators, and composed them in DI. Characterisation tests guarded the change. **Result:** the class count dropped to [6], push took [one day], and a policy fix now lives in exactly one place.
Last updated 30 Oct 2026.