Composition as building objects from owned parts and delegating to them, with strategies and decorators as common forms.
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.
Composition means building a class from smaller objects it holds and delegates to, instead of inheriting behaviour. The outer object controls the collaboration and exposes its own focused API; the parts each do one job. It's the default way to reuse and vary behaviour in modern C# — "has-a" rather than "is-a" — and it pairs naturally with dependency injection.
public sealed class InvoiceGenerator(
ITaxCalculator tax,
IInvoiceNumberGenerator numbers,
IInvoiceRenderer renderer,
TimeProvider clock)
{
public async Task<InvoiceDocument> GenerateAsync(Order order, CancellationToken ct)
{
var lines = order.Lines.Select(l => new InvoiceLine(l.Description, l.Amount, tax.For(l))).ToList();
var number = await numbers.NextAsync(ct);
var invoice = new Invoice(number, clock.GetUtcNow(), order.Customer, lines);
return await renderer.RenderAsync(invoice, ct); // PDF, HTML... delegated
}
}
InvoiceGenerator owns the workflow; tax rules, numbering, rendering and time are separate collaborators. Each can be swapped, tested and changed independently.
Composition also applies inside the domain: an Order owns its OrderLines and an Address value object — they're part of the order and live and die with it.
The outer class forwards work to its parts, sometimes adding behaviour around them:
public sealed class CachingProductCatalog(IProductCatalog inner, IMemoryCache cache) : IProductCatalog
{
public Task<Product?> FindAsync(string sku, CancellationToken ct) =>
cache.GetOrCreateAsync($"product:{sku}", e =>
{
e.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(5);
return inner.FindAsync(sku, ct);
});
}
That's the decorator pattern — adding caching without modifying or inheriting from the real catalog. ASP.NET Core's middleware pipeline, DelegatingHandlers for HttpClient, and Polly resilience pipelines are all composition.
Change
Testing
FakeTimeProvider, an in-memory renderer).Reuse
ITaxCalculator used by quotes, invoices and refunds).These are usually worth it; for genuinely hierarchical, stable is-a relationships, inheritance can still be simpler.
A reporting service had ReportBase with subclasses for each report × output format × delivery channel — 37 classes. Adding "deliver to SharePoint" required touching a dozen. We recomposed it as Report = IReportQuery + IReportFormatter + IReportDelivery, configured per report in a table. The class count dropped, new channels became one class, and tests for formatters no longer needed database fixtures.
Checks whether the candidate can design flexible classes without deep hierarchies.
In UML, composition (filled diamond) is the strongest form of association: the whole owns the part, and the part's lifetime depends on the whole (an order line can't exist without its order). Aggregation (hollow diamond) is a weaker "has-a" where parts can be shared or outlive the whole. In everyday design discussions, "composition over inheritance" uses composition more loosely to mean "assemble behaviour from collaborating objects" — including services injected by DI, which the composite doesn't own exclusively.
| Pattern | Idea | .NET example |
|---|---|---|
| Strategy | Swap an algorithm behind an interface | IComparer<T>, IPasswordHasher<T> |
| Decorator | Wrap to add behaviour, same interface | BufferedStream, DelegatingHandler, caching repositories |
| Composite | Treat a group like a single item | CompositeChangeToken, composite validators |
| Chain of responsibility | Pass request through handlers | ASP.NET Core middleware, MediatR pipeline behaviours |
| Adapter | Wrap to change an interface | Wrapping a vendor SDK behind IPaymentGateway |
| Facade | Simplify a set of collaborators | InvoiceGenerator above |
The built-in container doesn't support decoration directly; options:
services.AddScoped<IProductCatalog>(sp => new CachingProductCatalog(sp.GetRequiredService<SqlProductCatalog>(), sp.GetRequiredService<IMemoryCache>()));services.Decorate<IProductCatalog, CachingProductCatalog>().When composing, decide who owns each part:
new OrderLine(...)) are owned — dispose them if disposable.DbContext shared across requests). ValidateScopes in Development catches this.public sealed class Order
{
private readonly List<OrderLine> _lines = []; // owned parts
public ShippingAddress Address { get; private set; } // value object, owned
public CustomerId CustomerId { get; } // reference by ID to another aggregate (not owned)
}
In DDD terms, an aggregate is a composition boundary: the root owns its parts, enforces invariants across them, and is persisted as a unit. Other aggregates are referenced by ID, not composed. EF Core models owned parts with owned types or ComplexProperty (EF Core 8) for value objects, and cascade deletes for owned children.
When wrapping a large interface (e.g. Stream), you must forward many members. Options: inherit from a base that forwards (Stream wrappers often derive from Stream and forward), source generators that implement forwarding, or design smaller interfaces so wrappers stay small (Interface Segregation helps composition).
ValidateScopes/ValidateOnBuild and look for captive dependencies.ComplexProperty and observe the table columns.public interface INotifier
{
Task SendAsync(string to, string message, CancellationToken ct);
}
public sealed class EmailNotifier(IEmailClient client) : INotifier
{
public Task SendAsync(string to, string message, CancellationToken ct) =>
client.SendAsync(to, "Notification", message, ct);
}
// Decorators: same interface, wrap another INotifier
public sealed class RetryingNotifier(INotifier inner, int attempts = 3) : INotifier
{
public async Task SendAsync(string to, string message, CancellationToken ct)
{
for (int i = 1; ; i++)
{
try { await inner.SendAsync(to, message, ct); return; }
catch (HttpRequestException) when (i < attempts)
{
await Task.Delay(TimeSpan.FromMilliseconds(200 * i), ct);
}
}
}
}
public sealed class LoggingNotifier(INotifier inner, ILogger<LoggingNotifier> log) : INotifier
{
public async Task SendAsync(string to, string message, CancellationToken ct)
{
log.LogInformation("Sending notification to {To}", to);
await inner.SendAsync(to, message, ct);
}
}
// Composition with owned parts (strict UML composition): lines live and die with the order
public sealed class Order
{
private readonly List<OrderLine> _lines = []; // owned, not shared
public IReadOnlyList<OrderLine> Lines => _lines;
public void AddLine(string sku, int qty) => _lines.Add(new OrderLine(sku, qty));
}
public sealed record OrderLine(string Sku, int Quantity);
// Wiring the chain
INotifier notifier = new LoggingNotifier(
new RetryingNotifier(new EmailNotifier(emailClient)), logger);
What happens: each variant is a subclass, especially across multiple dimensions. Why it is wrong: combinatorial class explosion and fragile base classes. Do instead: compose independent parts (strategies) for each dimension.
What happens: a class news up its collaborators internally (new SmtpClient()).
Why it is wrong: hard to test or swap; hidden dependencies.
Do instead: inject collaborators through the constructor behind interfaces where variation or testing matters.
What happens: one class composes fifteen services. Why it is wrong: too many responsibilities; constructor over-injection is a design smell. Do instead: split into smaller composites or introduce facades for cohesive groups.
What happens: a singleton composes a scoped DbContext.
Why it is wrong: the context is shared across requests — thread-safety bugs and stale data.
Do instead: match lifetimes, or inject IServiceScopeFactory/factories in singletons.
What happens: a composite disposes services it received from DI. Why it is wrong: the container owns them; other consumers then use disposed objects. Do instead: dispose only what you create.
What happens: a caching decorator changes semantics (returns stale data where callers need fresh, swallows exceptions). Why it is wrong: violates the interface contract callers rely on. Do instead: document and preserve semantics; make staleness explicit.
What happens: aggregates hold object references to other aggregates (Order.Customer fully loaded).
Why it is wrong: blurs ownership and transactional boundaries, encourages huge object graphs.
Do instead: compose owned parts by reference; reference other aggregates by ID.
What happens: every tiny helper gets an interface and DI registration. Why it is wrong: wiring noise without benefit. Do instead: inject boundaries and real variation points; use plain classes or static pure functions for the rest.
Seniors reach for composition by default because it localises change, and they use the right composition pattern for the axis of variation.
| Axis of change | Composition tool |
|---|---|
| Algorithm varies | strategy interface or delegate |
| Cross-cutting concerns (retry, cache, log) | decorators |
| Ordered processing | pipeline / middleware |
| Parts owned by an aggregate | private fields, created inside |
| Parts provided by infrastructure | constructor injection |
They also know composition is how .NET itself is built: Stream decorators, HttpMessageHandler chains, middleware and IOptions all favour has-a over is-a.
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.
Both are whole/part associations. In aggregation the parts exist independently and can be shared or move between wholes: a Team has Players, but a player exists without the team and can transfer. In composition the whole owns its parts exclusively; they are created with it and destroyed with it: an Order and its OrderLines. The practical consequences: composition implies cascade delete, parts are created inside the whole and not exposed for outside modification; aggregation implies no cascade, parts are referenced (often by ID) and managed elsewhere. In EF Core, composition maps naturally to owned types or required relationships with cascade delete; aggregation maps to optional or many-to-many relationships without cascade.
How to tell it (STAR)
**Situation:** At [company] our notification code had nine classes for combinations of channel, retry and logging, and adding push notifications meant writing four more. **Task:** I wanted to add the new channel without growing the hierarchy, and fix a retry bug once. **Action:** I saw two independent axes squeezed into one inheritance tree. I introduced an `INotifier` interface, kept one class per channel, and moved retry and logging into decorators that wrap any notifier. DI composes the chain. I wrote unit tests for each decorator. **Result:** Nine classes became four, push notifications needed one new class, and the retry fix applied to every channel automatically.
Last updated 30 Oct 2026.