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

Same interface, extra behavior, zero changes to the original class — and you can stack more than one.

Suppose EmailNotificationSender works fine, but now you need every send attempt logged, and separately, you need failed sends automatically retried. You could edit EmailNotificationSender directly to add logging and retry logic — but now every other class that also implements INotificationSender needs the same edit repeated, and you've permanently coupled "sending an email" with "logging" and "retrying," even for the one caller that doesn't want retries.

The Decorator Pattern — a Structural pattern, one of the three GoF categories from the design patterns lesson earlier in this Part — solves this by wrapping an object instead of editing it. A decorator implements the exact same interface as the thing it wraps, holds a reference to it, and adds behavior before, after, or around each delegated call — leaving the original class, and every other instance of it, completely untouched.

In this lesson, you'll learn the mechanical shape of Decorator, build a logging-and-retry notification sender by stacking two decorators together, and see the general principle behind it.

What Is It?

The Simple Explanation

A decorator wraps an object of the same type, adding behavior around it without changing its source code or affecting other instances. From the outside, a decorated object looks and is used exactly like the thing it wraps — because it implements the same interface — but calling it also runs the decorator's extra logic.

The Technical Definition

Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassing for extending functionality — instead of an EmailSenderWithLoggingAndRetry subclass (which locks that specific combination in permanently), you compose small, single-purpose wrapper objects at runtime, in whatever combination a given caller actually needs.

The mechanical shape — memorize this

A decorator: (1) implements the same interface as the thing it wraps; (2) holds a reference to the wrapped instance, typically injected via its constructor; (3) delegates the actual work to that wrapped instance; (4) adds its own logic before, after, or around the delegated call. Because a decorator satisfies the same interface, it can itself be wrapped by another decorator — that's what makes stacking possible.

Why Does It Exist? — The Problem It Solves

Without Decorator, adding a cross-cutting behavior to one class tends to either bloat that class directly, or spawn a combinatorial explosion of subclasses:

//  WITHOUT Decorator — logging and retry logic baked directly into the class,
// mixed in with the class's actual job (sending an email)
public sealed class EmailNotificationSender : INotificationSender
{
    public async Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"Sending to {recipient}...");           // logging concern
        for (int attempt = 1; attempt <= 3; attempt++)              // retry concern
        {
            try
            {
                await SmtpClient.SendAsync(recipient, message);
                Console.WriteLine("Sent successfully.");            // logging concern
                return;
            }
            catch (SmtpException) when (attempt < 3) { }
        }
    }
}
// Want an SmsNotificationSender with the SAME logging+retry behavior?
// Copy-paste this whole mess again. Want a sender with logging but NOT retry?
// You can't — they're welded together inside one method.
PROBLEM → NEED → SOLUTION
PROBLEM
NEED
SOLUTION

Big Picture — Stacking Decorators

A CALL PASSING THROUGH TWO STACKED DECORATORS
RetryingNotificationSender.SendAsync() called
→ delegates to → LoggingNotificationSender.SendAsync()
→ delegates to → EmailNotificationSender.SendAsync()

Each layer only knows about the layer immediately inside it, through the shared INotificationSender interface. EmailNotificationSender was never edited to add logging or retry — both were bolted on entirely from the outside, and can be added, removed, or reordered independently.

How It Works

BUILDING A DECORATOR — STEP BY STEP
1. START WITH THE SHARED INTERFACE
2. THE DECORATOR ALSO IMPLEMENTS THAT INTERFACE
3. IT HOLDS A REFERENCE TO THE WRAPPED INSTANCE
4. IT DELEGATES, ADDING BEHAVIOR AROUND THE CALL
5. COMPOSE AT CONSTRUCTION TIME, IN WHATEVER ORDER YOU NEED

Simple Example

public interface INotificationSender
{
    Task SendAsync(string recipient, string message);
}

// ── The real worker — never modified, has no idea it's wrapped ──
public sealed class EmailNotificationSender : INotificationSender
{
    public Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"[Email] To {recipient}: {message}");
        return Task.CompletedTask;
    }
}

// ── A decorator — same interface, wraps another INotificationSender, adds behavior ──
public sealed class LoggingNotificationSender(INotificationSender inner) : INotificationSender
{
    public async Task SendAsync(string recipient, string message)
    {
        Console.WriteLine($"[Log] Sending to {recipient}...");
        await inner.SendAsync(recipient, message);       // delegate to the wrapped instance
        Console.WriteLine("[Log] Send completed.");
    }
}

// ── Usage ──
INotificationSender sender = new LoggingNotificationSender(new EmailNotificationSender());
await sender.SendAsync("alice@example.com", "Your order shipped!");
// [Log] Sending to alice@example.com...
// [Email] To alice@example.com: Your order shipped!
// [Log] Send completed.

Code → Meaning → Result: sender is typed as INotificationSender — the calling code has no idea it's actually a LoggingNotificationSender wrapping an EmailNotificationSender. EmailNotificationSender's source code was never touched, and any other INotificationSender implementation could be logged the exact same way, for free.

Real-World Example — Stacking Logging AND Retry

public sealed class RetryingNotificationSender(INotificationSender inner, int maxAttempts = 3)
    : INotificationSender
{
    public async Task SendAsync(string recipient, string message)
    {
        for (int attempt = 1; attempt <= maxAttempts; attempt++)
        {
            try
            {
                await inner.SendAsync(recipient, message);   // delegate — success, done
                return;
            }
            catch (Exception) when (attempt < maxAttempts)
            {
                await Task.Delay(TimeSpan.FromSeconds(attempt));  // simple backoff, then retry
            }
        }
    }
}

// ── Stack BOTH decorators — order matters! ──
INotificationSender sender =
    new RetryingNotificationSender(               // outermost: retries the WHOLE inner pipeline
        new LoggingNotificationSender(             // middle: logs each attempt, including retries
            new EmailNotificationSender()));        // innermost: the real work

await sender.SendAsync("bob@example.com", "Payment received");
// A failed send now gets logged AND retried — neither EmailNotificationSender
// nor LoggingNotificationSender was ever modified to add retry behavior.

// ── Registering this composition in DI ──
services.AddSingleton(sp =>
    new RetryingNotificationSender(
        new LoggingNotificationSender(
            new EmailNotificationSender())));

Notice the order is deliberate: wrapping Retry around Logging means each retry attempt gets its own log entries — swap the order and you'd log only the first attempt, then silently retry inside the logger's delegation. Decorator composition order is a real design decision, not an arbitrary choice.

Analogy — Gift Wrapping

A wrapped gift is still a gift

A wrapped present is still, fundamentally, a present — you can hand it to someone the same way, and it responds to "open me" the same way. The wrapping paper adds something (presentation) without changing what's inside. Add a ribbon on top of the wrapping paper, and you've stacked a second layer — the box itself was never touched by either layer. Unwrap it, and the original gift is exactly as it always was. That's Decorator: each layer adds something, the core object is untouched, and layers can be added or removed independently.

Under the Hood — The Composition Mechanics

WHAT ACTUALLY HAPPENS AT CONSTRUCTION AND CALL TIME
1. AT CONSTRUCTION — A CHAIN OF REFERENCES IS BUILT
2. AT CALL TIME — EACH DELEGATION IS AN ORDINARY VIRTUAL/INTERFACE CALL
3. THE CALL STACK LITERALLY MIRRORS THE WRAPPING ORDER
A real, built-in .NET example — DelegatingHandler. ASP.NET Core's HttpClient pipeline (Intermediate 130) is built around message handlers, and DelegatingHandler is specifically designed to be chained: it holds an InnerHandler, and a custom handler overrides SendAsync to add behavior (auth headers, logging, retry) before calling base.SendAsync to continue down the chain to the next handler. It's the same wrap-and-delegate shape as this lesson's decorators, applied to outgoing HTTP requests instead of notification sends.

Common Confusion

Decorator vs. inheritance — why not just subclass?

A subclass like LoggingEmailNotificationSender : EmailNotificationSender locks the combination in at compile time — you get exactly that one fixed pairing, and if you also want retry, you're either writing a third subclass for every combination (a combinatorial explosion: logging, retry, logging+retry, ...) or tangling multiple concerns into one override. Decorator composes the same behaviors at runtime, in any combination, without a new class per combination.

Decorator vs. Adapter — both "wrap" something, but for different reasons

Both patterns hold a reference to another object and delegate to it — but Decorator wraps an object in the same interface to add behavior, while Adapter (lesson 243) wraps an object to convert its interface into a different, incompatible one the caller expects. If the wrapper's interface matches the wrapped thing's interface, it's Decorator; if the interfaces are different shapes, it's Adapter.

Common Mistakes

Mistake 1 — Forgetting to delegate, and accidentally reimplementing the wrapped behavior

A decorator that logs, but then does its own SMTP call instead of calling inner.SendAsync(...) — this isn't decorating anymore, it's a second, divergent implementation that happens to also log. The delegated call to the wrapped instance must always happen; the decorator's entire value is adding behavior around that call, not replacing it.

Mistake 2 — Not noticing that decorator order changes behavior

Assuming new Retrying(new Logging(x)) and new Logging(new Retrying(x)) behave identically — they don't, as this lesson's real-world example showed. Reason explicitly about which layer should be "outermost" (runs first, sees the whole inner pipeline) versus "innermost" (closest to the real work).

Mistake 3 — Decorating when the behavior isn't actually optional or combinable

Building a decorator for something that should always happen for every single call, with no scenario where it's skipped — that's just... part of the class's normal behavior, not something worth externalizing. Decorator earns its cost when the behavior is genuinely optional, needs to be combined in different ways for different callers, or needs to be reused across multiple unrelated implementations of the same interface.

When Should I Use It?

And when it's overkill: for a behavior that's always required, for every caller, with no variation — just put it directly in the class. Wrapping a single always-on behavior in a decorator adds an object, a constructor parameter, and a mental indirection for zero actual flexibility gained.

Rule of thumb: if you can imagine a caller legitimately wanting the object without this behavior, or wanting a different combination of behaviors than another caller, that's a decorator. If the behavior is universal and non-negotiable, it belongs inside the class itself.

Mental Model

Decorator = same interface + a wrapped reference + delegation + added behavior around the call.
Stacking = wrap a decorator in another decorator — order changes what runs first.
The original class = never modified, never even aware it's wrapped.

Remember:
· If the wrapper's interface matches what it wraps, it's Decorator. If it converts to a different interface, that's Adapter (243).
· Always delegate to the wrapped instance — a decorator that skips delegation isn't decorating anymore.
· DelegatingHandler in HttpClient's pipeline is this exact shape, built into .NET.

Key Takeaway


Check Your Understanding

You've built a stacked logging-and-retry decorator pipeline. Let's check you understand the mechanics.

1. What must be true for a class to correctly be called a "decorator" of another class?

Show answer

Correct: B

Why B is correct: This is the mechanical shape defined in "What Is It?" — same interface, held reference, delegation, added behavior. All four elements need to be present.

Why A is incorrect: Decorators use composition (holding a reference), not inheritance from the wrapped class — that's precisely what avoids the combinatorial subclass explosion.

Why C is incorrect: DI registration is optional convenience, not part of the pattern's definition — decorators can be composed with plain new calls, as this lesson's examples show.

Why D is incorrect: Converting to a different interface is Adapter (243), not Decorator — the interfaces must match for it to be a decorator.

Reinforcement: Same interface + held reference + delegation + added behavior is the full checklist for Decorator.

2. Given new RetryingNotificationSender(new LoggingNotificationSender(new EmailNotificationSender())), what happens if a send attempt fails on the first try but succeeds on the second?

Show answer

Correct: B

Why B is correct: RetryingNotificationSender is outermost, so each of its retry attempts calls into LoggingNotificationSender.SendAsync again — the logging layer runs, and logs, on every attempt, exactly as the real-world example described.

Why A is incorrect: Logging wraps EmailNotificationSender directly and is invoked on every call that reaches it, including retried ones.

Why C is incorrect: Retry catching an exception and retrying is exactly its designed job — nesting order doesn't prevent that.

Why D is incorrect: EmailNotificationSender has no awareness of being wrapped at all — that's the entire point of the pattern; wrapped objects are oblivious to their decorators.

Reinforcement: The outermost decorator's behavior wraps everything inside it, including repeated calls into inner layers on retry.

3. Why does ASP.NET Core's DelegatingHandler (used in HttpClient pipelines) count as a real example of the Decorator pattern?

Show answer

Correct: B

Why B is correct: A DelegatingHandler holds an inner handler reference and delegates to it after adding its own logic — mechanically identical to LoggingNotificationSender wrapping EmailNotificationSender.

Why A is incorrect: It doesn't convert protocols — that reframing would describe Adapter, not what DelegatingHandler actually does.

Why C is incorrect: Factory is about deciding which object to construct; DelegatingHandler is about wrapping and delegating an existing call — a Decorator concern.

Why D is incorrect: DelegatingHandler instances are specifically designed to be chained one after another, exactly like stacked decorators.

Reinforcement: Recognizing this shape in .NET's own libraries reinforces that Decorator isn't just a textbook exercise — it's how real HTTP middleware pipelines are built.

4. A team needs every implementation of INotificationSender to always log, with absolutely no exceptions or configuration — logging should never be optional or skippable for any caller. What's the best approach?

Show answer

Correct: B

Why B is correct: The "When Should I Use It?" guidance is explicit: a behavior that's always required for every caller with no variation belongs directly in the class. Decorator's value comes from optionality and combinability — neither applies here.

Why A is incorrect: Relying on every caller to remember to wrap a mandatory behavior is fragile — it's easy to forget, defeating the "no exceptions" requirement.

Why C is incorrect: Being a cross-cutting concern isn't sufficient on its own — Mistake 3 specifically warns against decorating behavior that isn't actually optional or combinable.

Why D is incorrect: A shared base class reintroduces the same combinatorial rigidity Decorator was designed to avoid, and doesn't fit this scenario any better than a decorator would.

Reinforcement: Decorator earns its cost specifically when behavior is optional or needs to combine differently per caller — universal, mandatory behavior is simpler as part of the class itself.

You now know how to add behavior to any object from the outside — without touching its source, and without a subclass explosion — and can spot the exact same shape in .NET's own HTTP pipeline.


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