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.
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.
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.
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.
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.
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.
INotificationSender — both the real sender and every decorator implement thisLoggingNotificationSender : INotificationSender — from the outside, indistinguishable from a "real" senderINotificationSender innerinner.SendAsync(...) → log again — the real work still happens in the wrapped instancenew RetryingNotificationSender(new LoggingNotificationSender(new EmailNotificationSender()))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.
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.
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.
new RetryingNotificationSender(new LoggingNotificationSender(new EmailNotificationSender())) creates three separate objects, each holding a reference to the next — a linked chain, all typed as INotificationSendersender.SendAsync(...) on the outermost decorator dispatches through the interface, exactly as any interface call does — there's no special runtime machinery, just one object calling a method on another it happens to hold a reference toRetryingNotificationSender.SendAsync → LoggingNotificationSender.SendAsync → EmailNotificationSender.SendAsync — the nesting you wrote at construction time becomes the call stack you'd see in a debuggerDelegatingHandler. 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.
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.
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.
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.
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).
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.
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.
DelegatingHandler in HttpClient's pipeline is this exact shape, built into .NET.
DelegatingHandler in ASP.NET Core's HttpClient pipeline is a real, built-in example of this exact shape.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?
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?
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?
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?
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.