← Open in the full interactive course (progress tracking, search & more)

You know "has-a." Now let's see composition as the actual load-bearing structure of every flexible, testable service you'll build.

In Foundations you saw composition as the polite alternative to inheritance — "a Car has an Engine." That's true, but it undersells what composition actually does in a real system. Watch what happens when a requirement changes on a composed design versus an inherited one:

// Requirement: "Orders over $10,000 need fraud review before discounting."
// With composition, this is a five-minute change: swap in a different strategy.
var pricer = new OrderPricer(
    discountStrategy: order.Total > 10_000
        ? new FraudReviewDiscountStrategy(new PercentageDiscountStrategy(0.1m))
        : new PercentageDiscountStrategy(0.1m));

// With inheritance, the same requirement usually means either:
//   1. a new subclass combination for every case that now needs fraud review, or
//   2. an if-check jammed into the base class that every subclass now carries around.
// Composition let the requirement become a runtime decision about which PARTS to plug in.

This is the real payoff of composition: it turns "which behavior does this object have" from a compile-time, hierarchy-shaped decision into a runtime, plug-in-shaped decision. That single shift is what makes composed systems easy to extend without touching existing, tested code.

In this lesson, you'll see composition as it's actually used in production: composing behavior by injecting interfaces, the Strategy-pattern shape that composition naturally takes, building flexible services from small composed parts, and how this sets up the Dependency Inversion Principle covered in the capstone lesson (081).

Composition, in Practice

You already know composition means holding a reference to another object and delegating to it. In real applications, the object you hold a reference to is almost always an interface, not a concrete class — that one detail is what unlocks everything else in this lesson.

Composition to a concrete type

Composition to an abstraction

This is composition wired to an interface instead of a class — and it's exactly the shape of the classic Strategy pattern: a class holds a reference to an interchangeable "strategy" object and delegates a specific piece of behavior to it, without knowing or caring which concrete strategy it actually received.

Why Composition Is the Backbone of Flexible Design

Every real application eventually needs to vary behavior along an axis that isn't known at the time the "container" class was written: different pricing rules per market, different notification channels per user preference, different export formats per customer request. If that variation is modeled with inheritance, you get a new subclass — or worse, a combinatorial explosion of subclasses — for every new combination. If it's modeled with composition, you get a new small class implementing an existing interface, plugged into a container that never has to change.

This is the mechanical reason "favor composition over inheritance" works in practice: composition lets you add behavior by adding a new, isolated class, instead of editing an existing, shared one. That's the Open/Closed Principle in action — open for extension (add a new strategy), closed for modification (the container class never changes).

Big Picture — Composing a Service from Small Parts

A REPORTING ENGINE BUILT ENTIRELY FROM COMPOSED PARTS
ReportEngine (the container)
composed of → IReportDataSource
composed of → IReportExporter
composed of → INotifier

Every axis of variation — where the data comes from, what format it's exported to, how completion is announced — is a separate, independently swappable interface. Adding "export to PDF" never touches ReportEngine, IReportDataSource, or any existing exporter.

Simple Example — The Strategy Shape

public interface IDiscountStrategy
{
    decimal Apply(decimal originalPrice);
}

public sealed class NoDiscount : IDiscountStrategy
{
    public decimal Apply(decimal originalPrice) => originalPrice;
}

public sealed class PercentageDiscount(decimal percent) : IDiscountStrategy
{
    public decimal Apply(decimal originalPrice) => originalPrice * (1 - percent);
}

public sealed class FixedAmountDiscount(decimal amount) : IDiscountStrategy
{
    public decimal Apply(decimal originalPrice) => Math.Max(0, originalPrice - amount);
}

// The container — composed with a strategy, doesn't know or care which one
public sealed class OrderPricer(IDiscountStrategy discountStrategy)
{
    public decimal GetFinalPrice(decimal originalPrice) => discountStrategy.Apply(originalPrice);
}

// Usage — the SAME OrderPricer class handles every case
var regular = new OrderPricer(new NoDiscount());
var sale = new OrderPricer(new PercentageDiscount(0.20m));
var clearance = new OrderPricer(new FixedAmountDiscount(15m));

Console.WriteLine(sale.GetFinalPrice(100m));   // 80

Code → Meaning → Result: OrderPricer is composed with whatever IDiscountStrategy it's given at construction — a primary constructor parameter, captured as a field automatically. Adding a new discount rule (a holiday discount, a loyalty discount) never means touching OrderPricer; it means writing one new class that implements IDiscountStrategy.

Real-World Example — A Notification Service Composed of Interchangeable Channels

A notification system needs to send messages through different channels, sometimes several at once, and the set of channels needs to grow without anyone touching the core service.

public interface INotificationChannel
{
    string ChannelName { get; }
    Task SendAsync(string recipient, string message, CancellationToken ct = default);
}

public sealed class EmailChannel(ISmtpClient smtp) : INotificationChannel
{
    public string ChannelName => "Email";

    public async Task SendAsync(string recipient, string message, CancellationToken ct = default)
    {
        await smtp.SendAsync(recipient, "Notification", message, ct);
    }
}

public sealed class SmsChannel(ISmsGateway gateway) : INotificationChannel
{
    public string ChannelName => "SMS";

    public async Task SendAsync(string recipient, string message, CancellationToken ct = default)
    {
        await gateway.SendTextAsync(recipient, message, ct);
    }
}

// The container class — composed with a COLLECTION of channels, not one hard-coded channel
public sealed class NotificationService(IEnumerable<INotificationChannel> channels)
{
    public async Task NotifyAsync(string recipient, string message, CancellationToken ct = default)
    {
        var tasks = channels.Select(channel => SendSafelyAsync(channel, recipient, message, ct));
        await Task.WhenAll(tasks);
    }

    private static async Task SendSafelyAsync(
        INotificationChannel channel, string recipient, string message, CancellationToken ct)
    {
        try
        {
            await channel.SendAsync(recipient, message, ct);
        }
        catch (Exception ex)
        {
            Console.WriteLine($"[{channel.ChannelName}] failed: {ex.Message}");
        }
    }
}

// Usage — composed at startup, e.g. via dependency injection (lesson 081)
var service = new NotificationService(
[
    new EmailChannel(smtpClient),
    new SmsChannel(smsGateway)
]);

await service.NotifyAsync("user@example.com", "Your order has shipped!");

Why this holds up as requirements change:

Analogy

A modular synthesizer, not a fixed instrument

An inherited hierarchy is like a fixed instrument — a piano is a piano; if you want it to sound different, you need a different instrument entirely (a new subclass). A composed system is a modular synthesizer: individual modules (oscillator, filter, envelope) each do one job, and you patch cables between them to build whatever sound you need. Want a new sound? Patch in a different filter module — you don't rebuild the synthesizer.

NotificationService is the synthesizer's chassis. Each INotificationChannel is a module you patch in. The chassis never changes; only the patch cables (which concrete implementations you compose it with) do.

Under the Hood

WHAT COMPOSITION ACTUALLY COSTS AND BUYS YOU
1. AN EXTRA LEVEL OF INDIRECTION
2. MORE OBJECTS, MORE WIRING
3. NO SHARED MEMORY LAYOUT, NO SHARED FRAGILITY

Common Confusion

Composition vs. the Strategy pattern — are they the same thing?

Composition is the general mechanism (a class holds and delegates to another object). Strategy is a specific, named use of composition: composing with an interface specifically to make one algorithm or behavior swappable at will. Every Strategy-pattern implementation uses composition; not every use of composition is "the Strategy pattern" (a Car holding an Engine is composition, but there's usually only ever one kind of engine in play at a time — no interchangeable strategy in the classic sense).

"Composing with interfaces" vs. Dependency Inversion — related, not identical

Composing a class with an interface field is the mechanism. Dependency Inversion (lesson 081) is the principle that says high-level modules (like NotificationService) should depend on abstractions (INotificationChannel) rather than concrete low-level details (EmailChannel). Everything in this lesson is already following that principle — you'll see it named and formalized in the capstone.

Common Mistakes

Mistake 1 — Composing with a concrete type instead of an abstraction

public sealed class OrderPricer
{
    private readonly PercentageDiscount _discount = new(0.1m); //  locked to one concrete strategy
}

Depend on the interface (IDiscountStrategy) and receive the concrete implementation from outside — you get all the flexibility of composition only when the field type is the abstraction, not the implementation.

Mistake 2 — Over-fragmenting into interfaces with a single, never-changing implementation

Wrapping every helper method behind its own interface "just in case," even when there's exactly one implementation and no realistic scenario for a second one. This adds indirection and files without buying any real flexibility.

Introduce an interface when there's a genuine reason to swap implementations (production vs. test, multiple real variants, or a team boundary) — not reflexively for every class.

Mistake 3 — Letting the container class know too much about its parts

public async Task NotifyAsync(string recipient, string message)
{
    foreach (var channel in channels)
    {
        //  NotificationService now knows about specific channel TYPES —
        // defeats the purpose of composing against an interface
        if (channel is SmsChannel sms && !IsValidPhoneNumber(recipient))
            continue;
        await channel.SendAsync(recipient, message);
    }
}

Push channel-specific logic (like validating a phone number) inside the channel's own implementation of SendAsync, not into the container that's supposed to treat every channel uniformly.

When Should I Use It?

Rule of thumb: if you find yourself asking "what if this needs to work differently depending on X," and X is a runtime condition (user preference, configuration, tenant, environment) rather than a fixed, permanent type distinction — that's composition's job, not inheritance's.

Mental Model

Inheritance = choosing the class's identity at compile time.
Composition (to an interface) = choosing the class's behavior at construction time — or even at runtime.

Remember:
· A container class composed of interfaces never needs to change to support a new implementation of any of them.
· The Strategy pattern is just composition, aimed deliberately at one swappable behavior.
· New requirement → new class implementing an existing interface, not an edit to a shared one.

Key Takeaway


Check Your Understanding

Let's confirm you can recognize composition-based design and know why it's structured this way.

1. Why does composing a class with an interface (like IDiscountStrategy) provide more flexibility than composing with a concrete class (like PercentageDiscount) directly?

Show answer

Correct: B

Why B is correct: Because the field's declared type is the interface, any current or future implementation can be substituted without editing the container class at all — that's the entire mechanism behind the Strategy-pattern shape shown in this lesson.

Why A is incorrect: Interface dispatch is not inherently faster than a direct call to a concrete type; the benefit here is design flexibility, not raw speed.

Why C is incorrect: Concrete classes can absolutely be passed to constructors — the issue is that doing so locks the field to that one type.

Why D is incorrect: There's a meaningful difference: composing with a concrete type locks you to that one implementation, while composing with an interface leaves the choice open.

Reinforcement: Composition unlocks its full value only when the field type is the abstraction, not a specific implementation.

2. In the NotificationService example, what is the benefit of accepting IEnumerable<INotificationChannel> in the constructor, rather than hard-coding an EmailChannel and an SmsChannel as fields?

Show answer

Correct: B

Why B is correct: Accepting a collection of the abstraction means the exact set of channels — including adding a new one, like Slack — is decided by whoever constructs the service, not baked into the class itself.

Why A is incorrect: Compile time is unaffected by this design choice; the benefit is architectural flexibility, not build performance.

Why C is incorrect: This is a design choice specific to this scenario, not a language requirement.

Why D is incorrect: The design still depends entirely on the INotificationChannel interface — that's what makes the collection meaningful in the first place.

Reinforcement: Composing against a collection of an abstraction is a common, powerful extension of the basic Strategy shape.

3. What is the precise relationship between "composition" and "the Strategy pattern," as described in this lesson?

Show answer

Correct: B

Why B is correct: Composition is the general mechanism (holding and delegating to another object); the Strategy pattern is a specific, well-known application of that mechanism aimed at making one algorithm or behavior interchangeable.

Why A is incorrect: They are directly related — every Strategy-pattern implementation is built using composition.

Why C is incorrect: The relationship runs the other way: Strategy is a specific case of the broader composition mechanism, not the reverse.

Why D is incorrect: The Strategy pattern doesn't replace composition — it IS composition, applied to a specific problem.

Reinforcement: Recognizing this relationship helps you see the Strategy pattern in code even when nobody labeled it as such.

4. Which scenario is the strongest candidate for a composition-based (Strategy-shaped) design rather than inheritance?

Show answer

Correct: B

Why B is correct: Composition earns its keep exactly when behavior needs to vary at runtime or grow over time without modifying existing code — the discount strategy example in this lesson is a textbook case.

Why A is incorrect: A stable, permanent, one-time classification like Employee vs. Contractor is actually a reasonable fit for inheritance or a fixed type distinction — it doesn't need runtime swapping.

Why C is incorrect: With only one implementation and no expectation of more, introducing an interface purely for composition adds indirection without benefit (see Mistake 2 in this lesson).

Why D is incorrect: A purely private, uncalled-from-outside detail has no reason to be abstracted behind a composed interface at all.

Reinforcement: Composition shines specifically where variation is expected, runtime-driven, or open-ended.

You can now recognize the Strategy shape everywhere — and you're set up perfectly for polymorphism (074), which explains exactly how that runtime dispatch through an interface actually works.


dotnetmadeeasy.com — Learn C# and .NET, the right way.