📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

Composition or inheritance: how do you decide which to use in a C# design? Walk through the criteria you apply, a case where inheritance is the right answer, and how you would refactor a hierarchy that has gone wrong.

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.

Senior Architecture Advanced composition csharp design inheritance liskov oop refactoring

🎯 Key points

  • Default to composition: depend on interfaces, inject collaborators, change behaviour at run time
  • Inheritance couples the subclass to the base implementation (fragile base class problem)
  • Use inheritance for a true is-a with full substitutability (Liskov), one axis of variation, and a base designed for extension
  • Framework extension points (BackgroundService, DelegatingHandler, DbContext) are good inheritance cases
  • Warning signs: NotSupportedException overrides, combinatorial subclass names, deep hierarchies, protected fields
  • Refactor by extracting each axis of variation into a strategy interface and using decorators for cross-cutting policies
  • Composition costs more wiring; keep the composition root readable
  • Seal leaf classes and keep any hierarchy shallow

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. The criteria I apply

I choose inheritance only if all of these are true:

  1. Substitutability (Liskov): every subtype can be used wherever the base is expected, honouring all its contracts — no NotSupportedException, no weaker guarantees.
  2. A stable, single axis of variation: subtypes vary in one dimension that's unlikely to multiply (not format × channel × schedule).
  3. Shared invariants and real shared behaviour belong in the base (not just a few helper methods).
  4. I control the base class (same team/repo), or it's a framework extension point designed for inheritance.
  5. The relationship is fixed for an object's lifetime (an object can't change its class at run time).
  6. The hierarchy stays shallow (one or two levels).

Otherwise I prefer composition: inject collaborators (strategies, policies, decorators), which gives:

  • variation along multiple independent axes without class explosion,
  • run-time selection and replacement,
  • coupling only to public contracts (no fragile base class),
  • easier testing of each part.

2. A case where inheritance is right

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.

3. Signs a hierarchy has gone wrong

  • Combinatorial explosion: PdfEmailReport, PdfSftpReport, CsvEmailReport...
  • Subclasses that override to do nothing or throw (NotSupportedException) — Liskov violations.
  • Deep hierarchies where behaviour requires reading 4–5 classes.
  • Base classes full of protected helpers used by some subclasses, with flags like if (this is SpecialImporter).
  • "BaseService"/"BaseRepository" inherited just for logging or helpers.
  • Changes to the base routinely break subclasses.
  • Mocking pain: tests subclass production classes to override pieces.

4. How I'd refactor a hierarchy that's gone wrong

Incrementally, with tests as a safety net:

  1. Characterisation tests around the current behaviour of each subclass (approval/snapshot tests are useful for reports).
  2. Identify the axes of variation — e.g. data source, format, delivery.
  3. Extract each axis into an interface (IReportQuery, IReportFormatter, IReportDelivery), implementing them by moving code out of subclasses.
  4. Make the base class delegate to those strategies (it becomes a composition root for the workflow), so existing subclasses keep working while you migrate.
  5. Replace subclasses with configuration: a report definition composed of strategies, registered in DI (keyed services work well).
  6. Delete the hierarchy once no subclasses remain; seal the new classes.
  7. Watch for behaviour that relied on override order or protected state — that's where bugs hide; tests catch it.

This is the "Replace Inheritance with Delegation" and "Replace Subclass with Strategy" refactorings (Fowler).

5. A production example (how I'd tell it)

[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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Why "favour composition over inheritance"

From Design Patterns (GoF, 1994): "Favour object composition over class inheritance." Reasons:

  • White-box vs black-box reuse: inheritance exposes the parent's internals to subclasses (white-box), composition uses objects through interfaces (black-box).
  • Static vs dynamic: inheritance is fixed at compile time; composition can change at run time.
  • Encapsulation: inheritance breaks encapsulation — subclasses depend on base implementation details (fragile base class).

2. Liskov in practice

Checks I use in reviews:

  • Would a caller written against the base type behave correctly with this subtype for all inputs it accepts?
  • Does the subtype require more (stronger preconditions) or promise less (weaker postconditions)?
  • Does it keep the base's invariants?
  • Does it introduce new exceptions callers don't expect?

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).

3. The expression problem and choosing axes

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.

4. Composition mechanics in .NET

  • Constructor injection of strategies, resolved by DI.
  • Keyed services (.NET 8): services.AddKeyedScoped<IReportFormatter, PdfFormatter>("pdf"), resolved by key from configuration.
  • Decorators (Scrutor) for cross-cutting behaviour (logging, caching, retries) instead of base-class hooks.
  • Default interface methods are not a substitute for base classes — no state.
  • Delegation boilerplate: for wrappers over large interfaces, consider source generators or smaller interfaces.

5. Inheritance in EF Core and serialisation

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.

6. A decision table

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)

Try it yourself

  1. Take a two-axis hierarchy in your code and sketch the strategy-based design.
  2. Write characterisation tests before refactoring.
  3. Implement "Replace Subclass with Strategy" for one subclass.
  4. Register strategies with keyed services and select them by configuration.

💻 Code

// 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);
    }
}

⚠ Common mistakes

1. Using "is-a" wording as the only test

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.

2. Inheritance for code reuse

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.

3. Big-bang hierarchy rewrites

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.

4. Over-applying composition

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.

5. Deep hierarchies

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.

6. Subclasses that throw NotSupportedException

What happens: subtypes can't support base operations. Why it is wrong: violates Liskov; callers break. Do instead: split interfaces or redesign the hierarchy.

7. Inheriting from types you don't control

What happens: subclassing third-party classes to tweak behaviour. Why it is wrong: upgrades break your subclasses. Do instead: wrap them (adapter/decorator).

8. Not sealing after refactoring

What happens: new strategy classes left open. Why it is wrong: invites the same inheritance problems again. Do instead: seal by default.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate recommends composition Why: Checks composition mechanics and benefits
    How to answer

    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.

  2. When: Candidate mentions Liskov or fragile base classes Why: Checks inheritance mechanics and LSP depth
    How to answer

    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.

  3. When: Candidate cites template method as a valid inheritance case Why: Probes abstract class design
    How to answer

    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.

🛠 Real-world problems: how you handle them

Notification class explosion refactored to channels and decorators
Situation
A notification service had grown to [14] notifier classes combining channel (email, SMS) and policies (retry, batching, quiet hours) through inheritance.
Signals
Adding push notifications was estimated at [two weeks]; a bug fix in retry logic had to be copied into [five] classes, and one copy was missed.
Investigation
Mapped the hierarchy and found three independent axes: channel, delivery policy and scheduling. Every new value on one axis multiplied the classes.
Resolution
Introduced `IChannel` with one implementation per channel, and decorators `RetryingChannel` (Polly), `BatchingChannel` and `QuietHoursChannel`. Combinations are composed in DI with keyed services. Characterisation tests protected the behaviour while subclasses were removed.
Prevention
Design guideline: new behaviour on an existing axis is a new implementation or decorator, never a subclass; architecture test fails on any new class deriving from a concrete notifier.

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.

↩ Usually asked after

More in Design Judgment

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me