GoF Structural Pattern

Decorator Pattern

Wrap an object in another object to bolt on new behavior — without touching the original. Like adding toppings to a pizza: the pizza stays the same, but now it does more.

29 Q&As 6 Bug Studies 8 Pitfalls 4 Testing Strategies C# / .NET
Section 1

TL;DR

Decorator in one line: "Take something that already works, wrap it in a new layer that adds extra behavior, and the outside world can't tell the difference." You keep adding layers like Russian nesting dolls — each one adds something new without ever changing what's inside.

What: Imagine you have a plain coffee. You want to add milk, then caramel, then whipped cream. You don't throw away the coffee and make a new "MilkCaramelWhipCoffee" from scratch — instead, you wrap each addition around the previous one. That's the Decorator patternA structural design pattern that lets you attach new responsibilities to an object by placing it inside a wrapper object. The wrapper has the same interface as the original, so code that uses the original can use the decorated version without knowing the difference. Think of it as adding a phone case — the phone still works the same, but now it has extra protection.. You start with a base object, then wrap it inside another object that adds new behavior — and that wrapper looks exactly like the original from the outside. You can stack as many wrappers as you want. The key idea is compositionBuilding complex objects by combining simpler objects rather than inheriting from them. Instead of "a DecoratedCoffee IS A Coffee" (inheritance), you say "a MilkDecorator HAS A Coffee inside it" (composition). This is more flexible because you can mix and match wrappers at runtime. over inheritance: instead of creating dozens of subclasses for every combination, you compose behaviors by wrapping objects inside each other.

When: Use it when you need to add responsibilities to objects without creating an explosion of subclasses. The most common situation is when you have behavior that needs to be applied across many services — things like logging, caching, and validation. Instead of copy-pasting that logic everywhere, you wrap each service in a decorator. These shared behaviors are called cross-cutting concernsBehavior that cuts across multiple parts of your application — things like logging, caching, authentication, or retry logic. These aren't part of your core business logic, but they need to be applied in many places. Decorators let you wrap any service with these concerns without modifying the service itself.. Another classic use is stream processing — layering compressionReducing data size before writing or transmitting. In .NET, GZipStream and BrotliStream are decorator streams that compress bytes as they flow through. They wrap another stream and add compression transparently. on top of encryptionScrambling data so only authorized parties can read it. In .NET, CryptoStream is a decorator that wraps any stream and encrypts/decrypts bytes as they pass through — the code reading or writing doesn't need to know encryption is happening. on top of bufferingCollecting small reads/writes into larger chunks before passing them to the underlying stream. BufferedStream wraps any stream and adds an in-memory buffer, reducing the number of expensive I/O operations. It's a performance decorator.. Also middleware pipelines, where each layer handles one concern (authentication, then authorization, then routing).

In C# / .NET: The framework uses Decorator everywhere. The Stream class hierarchyIn .NET, Stream is the base class for all byte-level I/O. FileStream reads raw bytes from disk. BufferedStream wraps any stream to add a memory buffer. GZipStream wraps any stream to add compression. CryptoStream wraps any stream to add encryption. You stack them like layers — each one IS-A Stream, so they're interchangeable. is the textbook example — BufferedStream wraps FileStream, GZipStream wraps that, and CryptoStream wraps that. ASP.NET Core's middleware pipelineThe chain of components that handle HTTP requests in ASP.NET Core. Each middleware (authentication, logging, routing, etc.) wraps the next one. A request flows inward through the layers, then the response flows back outward — exactly like a decorator stack. is Decorator in action — each Use() call wraps the pipeline in another layer. And libraries like ScrutorA popular .NET library that adds assembly scanning and decoration support to Microsoft's built-in DI container. Its Decorate<TInterface, TDecorator>() method makes it trivial to wrap registered services with decorators — no manual wiring needed. make DI-based decoration a one-liner.

Quick Code:

Decorator-at-a-glance.cs
// .NET's built-in Stream decorators — stacking layers of behavior

await using var fileStream   = new FileStream("data.bin", FileMode.Open);
await using var buffered     = new BufferedStream(fileStream);      // adds buffering
await using var compressed   = new GZipStream(buffered,             // adds compression
                                   CompressionMode.Decompress);
using var reader             = new StreamReader(compressed);        // adds text decoding

// Each layer wraps the previous one. The outermost layer
// (reader) still reads from the stream chain transparently.
string content = await reader.ReadToEndAsync();

// Custom decorator — same idea, any interface:
public interface INotifier
{
    void Send(string message);
}

public class EmailNotifier : INotifier
{
    public void Send(string message)
        => Console.WriteLine($"Email: {message}");
}

// Decorator: wraps ANY INotifier, adds SMS on top
public class SmsDecorator : INotifier
{
    private readonly INotifier _inner;
    public SmsDecorator(INotifier inner) => _inner = inner;

    public void Send(string message)
    {
        _inner.Send(message);                           // forward to wrapped object
        Console.WriteLine($"SMS: {message}");           // add new behavior
    }
}

// Stack them: Email + SMS + Slack — any combo, at runtime
INotifier notifier = new EmailNotifier();
notifier = new SmsDecorator(notifier);                  // wrap with SMS
notifier = new SlackDecorator(notifier);                // wrap with Slack
notifier.Send("Server is down!");                       // all three fire
Section 2

Prerequisites

Decorator builds on several fundamental concepts. You don't need to be an expert in all of them, but having a basic familiarity will make everything in this page click faster.

If any of these feel shaky, take 10 minutes to review them first — it'll save you time in the long run. The most important prerequisites are the first two: interfaces and composition. If you get those, the rest of the pattern falls into place naturally.

Before diving in:
Interfaces & Polymorphism — A decorator works because both the original object and the wrapper share the same interface. The calling code doesn't know which one it's talking to. If "programming to an interface"Writing your code so it depends on an abstract contract (interface) rather than a specific class. This way, you can swap in a decorator, a mock, or any other implementation without changing the code that uses it. feels unclear, review that first. Composition over Inheritance — Decorator is the poster child for this principle. Instead of building a tall inheritance tree, you composeCombining objects by having one object hold a reference to another (HAS-A) rather than extending it (IS-A). A SmsDecorator HAS-A INotifier inside it. This lets you mix and match at runtime instead of being locked into a rigid class hierarchy. behavior by wrapping objects. If you're not comfortable with HAS-A versus IS-A, brush up on that first. Stream class basics — .NET's StreamThe abstract base class in System.IO for all byte-based input/output in .NET. FileStream, MemoryStream, NetworkStream — they're all Streams. Decorators like BufferedStream and GZipStream work by wrapping any Stream and adding behavior on top. hierarchy is the most famous Decorator in the framework. You don't need to be an expert, but knowing that FileStream reads from disk and streams can be wrapped will help the examples click. Constructor Injection / DI — In real projects, decorators are typically wired up through dependency injectionA technique where an object receives the things it needs (its dependencies) from the outside — usually through the constructor — instead of creating them itself. This makes it easy to swap implementations, including wrapping a real service with a decorator.. You pass the "inner" object through the constructor. Understanding DI basics will make Section 5's Scrutor example make sense. Basic SOLID (OCP & LSP) — Decorator is closely tied to the Open/Closed PrincipleSoftware entities should be open for extension (you can add new behavior) but closed for modification (you don't change existing code). Decorator follows this perfectly — you extend behavior by wrapping, not by editing the original class. (add behavior without modifying existing code) and the Liskov Substitution PrincipleAny subtype (or in this case, any wrapper implementing the same interface) should be usable wherever the base type is expected, without breaking anything. A decorator must behave like the original from the caller's perspective. (decorators must be substitutable for the original). async/await basics — Many real-world decorators wrap asynchronousCode that starts an operation and then moves on without waiting for it to finish. In C#, you use the async keyword and await to pause until the result is ready — without blocking the thread. Decorators that wrap async methods need to properly forward the await chain. methods. Understanding how async/await flows through a call chain will help you see how a decorator can intercept and augment async operations.
Section 3

Real-World Analogies

Instagram Filters

Think about editing a photo on Instagram. You start with a raw photo — that's your base object. Then you apply a Vintage filter — the photo now looks older, but the original pixels are still there underneath. Next you crank up the Brightness — that wraps the already-filtered photo in another layer of change. Then you add a Vignette (dark corners). Each filter wraps the previous result. You can stack as many as you want, in any order, and you can always peel one off if you don't like it.

Here's what makes this different from just "applying effects in a row." Each filter is non-destructiveThe original data is never modified. Each decorator works on a copy or wraps the original without changing it. In Instagram, the raw photo stays intact underneath all the filters. In code, the base component's behavior is unchanged — decorators only add to it.. The raw photo still exists underneath. You could undo the Vignette and still have Vintage + Brightness. You could save a version with just Vintage and another with all three. The layers are independent — they don't know about each other, and removing one doesn't break the others.

The key insight? Each filter takes in "a photo" and outputs "a photo." The Brightness filter doesn't care if it's working on a raw photo or one that's already been through Vintage — this is called transparencyIn the Decorator pattern, transparency means the wrapper looks identical to the original from the outside. Code that uses the decorated object doesn't know (or care) how many layers of wrapping are involved. Each decorator and the original implement the same interface, making them interchangeable. — it just does its thing. That's exactly how Decorator works in code: each wrapper takes in "an object that does X" and outputs "an object that still does X, but now with extra behavior."

Real WorldWhat it meansIn code
Raw photo The original object with basic behavior ConcreteComponent (e.g., EmailNotifier)
Any photo (raw or filtered) The shared contract — "anything that behaves like a photo" IComponent interface (e.g., INotifier)
A filter (Vintage, Brightness, etc.) A wrapper that adds one specific behavior ConcreteDecorator (e.g., SmsDecorator)
The "apply filter" mechanism The base wrapper that holds a reference to the inner object BaseDecorator class
Stack of filters (Vintage + Brightness + Vignette) Wrapping one decorator inside another — layered behavior new Vignette(new Brightness(new Vintage(photo)))
Airport Security

A passenger goes through security check, then passport control, then boarding pass scan. Each checkpoint wraps the journey — adding a new verification step — but the passenger is still the same person walking through. Each layer adds one responsibility without knowing what the others did. Add a customs check for international flights? Just insert one more checkpoint. The rest don't change.

Gift Wrapping

You buy a book, put it in a box, wrap the box in paper, then add a ribbon. Each layer wraps the previous one. Underneath all those layers, it's still the same book — you just added presentation on top. Peel off any layer and the rest still works. The book doesn't know it's wrapped. The paper doesn't know there's a ribbon. Each layer is independent.

Subway Sandwich

You start with bread, then add lettuce, tomato, cheese, sauce. Each topping is added independently, in any order, and you can combine them however you want. The bread doesn't care what's on it. Each topping is like a decorator — it enhances the sandwich without replacing it. Tomorrow Subway adds a new topping? One new ingredient, zero changes to the bread or existing toppings.

Section 4

Core Pattern & UML

The Gang of FourThe four authors (Gamma, Helm, Johnson, Vlissides) who wrote "Design Patterns: Elements of Reusable Object-Oriented Software" in 1994. Their 23 patterns — including Decorator — became the foundation of software design vocabulary. "GoF" is the standard shorthand. definition says: "Attach additional responsibilities to an object dynamically. Decorators provide a flexible alternative to subclassingCreating a new class that inherits from an existing class. The problem with subclassing for adding features is that you get locked into a fixed hierarchy at compile time, and combinations of features cause an explosion of subclasses (BoldText, ItalicText, BoldItalicText, etc.). for extending functionality." In plain English: instead of building a new class for every combination of features, you wrap objects in other objects — each wrapper adds one feature, and you can stack them however you like at runtimeWhen the program is actually running, as opposed to compile time (when you write and build the code). Decorator's power is that you choose which wrappers to apply while the program runs — based on config, user input, or conditions — rather than locking in the choice when you write the code..

UML Class Diagram

Decorator Pattern UML — IComponent interface with ConcreteComponent and BaseDecorator, which has ConcreteDecoratorA and ConcreteDecoratorB «interface» IComponent + Operation() : void ConcreteComponent + Operation() : void «abstract» BaseDecorator - _inner : IComponent + Operation() : void ConcreteDecoratorA + Operation() : void + AddedBehaviorA() ConcreteDecoratorB + Operation() : void + AddedBehaviorB() wraps

If you're new to UML diagrams: the dashed arrows with hollow arrowheads mean "implements" (the class follows the interface's contract). The solid arrow labeled "wraps" means "holds a reference to" — the BaseDecorator stores an IComponent inside it. The two boxes at the bottom are concrete decorators that extend BaseDecorator and add specific behavior.

Here's how the four participants work together. Think of it like a relay team — the baton (the method call) gets passed from the outermost decorator inward to the real object, then the result flows back out with each decorator adding its own touch along the way.

Notice that the Component interface is the glue holding everything together. Because both the ConcreteComponent and every Decorator implement it, the calling code only sees "something that does X." It doesn't know — and doesn't care — whether it's talking to the real object or one wrapped in 10 layers of decoration. This is what makes the pattern so powerful: you can add, remove, or reorder layers without changing a single line of the code that uses the decorated object.

ParticipantRoleResponsibility
ComponentThe shared interface (or abstract class) that both the real object and all decorators implement. This is what makes decorators transparent — the caller just sees "something that does X" and doesn't care how many wrappers are involved. Interface Defines the contract that both the real object and all decorators must follow. This shared shape is what allows unlimited stacking.
ConcreteComponentThe actual "thing" being decorated — the base object that does the core work. In the coffee analogy, this is the plain coffee before any toppings. In code, this might be EmailNotifier, FileStream, or PlainText. Base Object The original object with the core behavior. This is the innermost layer — the one that does the "real work" before any decorators add their extras.
DecoratorThe abstract base wrapper that holds a reference to the inner Component. It implements the Component interface and by default delegates all calls to the inner object. Concrete decorators extend this to add behavior before or after delegation. Base Wrapper Holds a reference to the wrapped object and delegates calls to it by default. Provides the "wrapping skeleton" that concrete decorators build on.
ConcreteDecoratorA specific decorator implementation that adds one well-defined behavior. Examples: SmsDecorator adds SMS sending, BufferedStream adds buffering, RetryHandler adds retry logic. Each one overrides one or more methods to inject its behavior while delegating the rest to the inner object. Specific Wrapper Overrides methods to add new behavior before and/or after calling the inner object. Each decorator adds exactly one responsibility — logging, caching, compression, etc.
Quick Mental Model

Think of it as a chain of responsibility, but additive. In Chain of Responsibility, only ONE handler responds. In Decorator, EVERY layer runs — each one adds its bit, then passes the call to the next. The innermost layer does the "real work," and every wrapper above it enhances the result.

The two-line summary: A decorator is a Component (same interface) and has a Component (wraps one inside). That's the entire pattern. IS-A for substitutability, HAS-A for wrapping.

Section 5

Code Implementations

Now that you understand the concept and the UML structure, let's see the pattern in actual C# code. We'll look at three different implementations, each suited to different situations.

The first is the textbook GoF approach — great for understanding the structure and seeing every moving part. The second uses .NET's own Stream class to show how the framework itself uses this pattern for I/O operations. The third uses a DI library called Scrutor to wire decorators automatically through the service containerThe dependency injection container in ASP.NET Core (IServiceProvider). It creates and manages object lifetimes. When you register a decorator with Scrutor's Decorate() method, the container automatically wraps the original service — you don't manually nest constructors. — this is how most real production ASP.NET Core projects do it, and it's the approach you'll likely use day-to-day.

Recommended

Start here. This is the cleanest way to understand the Decorator structure — one interface, one base object, one base decorator, and as many concrete decorators as you need. Once you grasp this version, the Stream and DI approaches will feel natural.

We'll build a notification system where you start with email, then layer on SMS and Slack without touching the original email code. Notice how every class implements INotifier — that's what makes them stackable. Click through the tabs below to see each piece of the pattern.

Step 1 — The interface: This is the contract that all notifiers (both real and decorators) must follow. It's intentionally simple — just one method.

INotifier.cs
// The Component interface — the shared contract
// Both the real notifier and every decorator implement this
public interface INotifier
{
    void Send(string message);
}

Step 2 — The concrete component: This is the "real" object that does the core work. It sends an email. No wrapping, no delegation — just the basic operation.

EmailNotifier.cs
// The ConcreteComponent — the "real" object that does the core work
public class EmailNotifier : INotifier
{
    public void Send(string message)
    {
        Console.WriteLine($"[EMAIL] Sending: {message}");
        // In real code: SmtpClient, SendGrid, etc.
    }
}

Step 3 — The base decorator: This abstract class is the "wrapping skeleton." It holds a reference to the inner notifier and forwards calls to it by default. Concrete decorators will extend this and add their own behavior before or after the forwarding.

NotifierDecorator.cs
// The Base Decorator — holds the inner notifier and forwards calls
// This is the "wrapping skeleton" that all concrete decorators build on
public abstract class NotifierDecorator : INotifier
{
    protected readonly INotifier _inner;

    protected NotifierDecorator(INotifier inner)
        => _inner = inner ?? throw new ArgumentNullException(nameof(inner));

    // By default, just pass the call through to the wrapped object
    public virtual void Send(string message) => _inner.Send(message);
}

Step 4a — Concrete decorator: Extends the base decorator and adds exactly one behavior — SMS sending. Calls base.Send() to let the inner notifier(s) do their thing, then adds its own.

SmsDecorator.cs
// Concrete Decorator #1 — adds SMS notification on top
public class SmsDecorator : NotifierDecorator
{
    public SmsDecorator(INotifier inner) : base(inner) { }

    public override void Send(string message)
    {
        base.Send(message);                             // let the inner object(s) do their thing first
        Console.WriteLine($"[SMS] Sending: {message}"); // then add our own behavior
    }
}

Step 4b — Another concrete decorator: Same idea, different behavior. SlackDecorator adds Slack posting. Notice it also takes a channel parameter — decorators can have their own configuration beyond the inner object.

SlackDecorator.cs
// Concrete Decorator #2 — adds Slack notification on top
public class SlackDecorator : NotifierDecorator
{
    private readonly string _channel;

    public SlackDecorator(INotifier inner, string channel = "#alerts")
        : base(inner)
    {
        _channel = channel;
    }

    public override void Send(string message)
    {
        base.Send(message);
        Console.WriteLine($"[SLACK → {_channel}] Sending: {message}");
    }
}

Step 5 — Stacking them together: This is where the magic happens. You create the base object, then wrap it in as many decorators as you want. The calling code just sees an INotifier — it has no idea how many layers are involved.

Program.cs — Stacking decorators
// Stack them however you want — at runtime, not compile time!
INotifier notifier = new EmailNotifier();                   // core: email only
notifier = new SmsDecorator(notifier);                      // wrap: email + SMS
notifier = new SlackDecorator(notifier, "#critical-alerts");// wrap: email + SMS + Slack

notifier.Send("Database connection pool exhausted!");
// Output:
// [EMAIL] Sending: Database connection pool exhausted!
// [SMS] Sending: Database connection pool exhausted!
// [SLACK → #critical-alerts] Sending: Database connection pool exhausted!

// Want just Email + Slack? Skip the SMS wrapper:
INotifier emailSlack = new SlackDecorator(new EmailNotifier());
emailSlack.Send("Disk space low.");
// Output:
// [EMAIL] Sending: Disk space low.
// [SLACK → #alerts] Sending: Disk space low.

How the Call Flows Through the Stack

When you call notifier.Send("Server is down!"), the call dives inward through each decorator to reach the core object, then each decorator adds its behavior on the way back out. This is exactly how ASP.NET Core middleware works too.

notifier.Send("Server is down!") SlackDecorator SmsDecorator EmailNotifier [EMAIL] Sending: Server is down! core operation — no wrapping, just sends 1 2 3 4 5 6 Output (in order): Step 4: [EMAIL] Sending: Server is down! Step 5: [SMS] Sending: Server is down! Step 6: [SLACK → #critical-alerts] Sending: Server is down! Call goes IN (1→2→3) Each decorator adds behavior on the way OUT (4→5→6)

The .NET Stream class is the framework's own decorator — it's been there since .NET 1.0. The abstract Stream class is the Component interface. FileStream and MemoryStream are concrete components. Everything else — BufferedStream, GZipStream, CryptoStream — are decorators that wrap another stream.

Let's build a custom stream decorator that counts how many bytes pass through it — useful for monitoring, progress bars, or bandwidth tracking. It wraps any Stream and adds byte-counting behavior without changing the underlying stream at all. Notice how we implement the full Stream contract: every property and method either adds our behavior or simply delegates to the inner stream.

CountingStream.cs
/// <summary>
/// A stream decorator that counts bytes read and written.
/// Wraps any existing stream — FileStream, MemoryStream, NetworkStream, etc.
/// </summary>
public sealed class CountingStream : Stream
{
    private readonly Stream _inner;

    public CountingStream(Stream inner)
        => _inner = inner ?? throw new ArgumentNullException(nameof(inner));

    public long BytesRead { get; private set; }
    public long BytesWritten { get; private set; }

    // ── Core overrides: add counting, then delegate ──
    public override int Read(byte[] buffer, int offset, int count)
    {
        int bytesRead = _inner.Read(buffer, offset, count);  // delegate
        BytesRead += bytesRead;                                // add behavior
        return bytesRead;
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        _inner.Write(buffer, offset, count);                   // delegate
        BytesWritten += count;                                  // add behavior
    }

    // ── Everything else: just forward to the inner stream ──
    public override bool CanRead  => _inner.CanRead;
    public override bool CanSeek  => _inner.CanSeek;
    public override bool CanWrite => _inner.CanWrite;
    public override long Length   => _inner.Length;
    public override long Position
    {
        get => _inner.Position;
        set => _inner.Position = value;
    }
    public override void Flush()          => _inner.Flush();
    public override long Seek(long o, SeekOrigin origin) => _inner.Seek(o, origin);
    public override void SetLength(long value)           => _inner.SetLength(value);

    protected override void Dispose(bool disposing)
    {
        if (disposing) _inner.Dispose();
        base.Dispose(disposing);
    }
}
Usage — stacking with built-in decorators
// Stack our custom CountingStream with .NET's built-in decorators
await using var file     = new FileStream("log.txt", FileMode.Open);
await using var counting = new CountingStream(file);          // our decorator
await using var buffered = new BufferedStream(counting);      // built-in decorator
await using var reader   = new StreamReader(buffered);        // built-in decorator

string content = await reader.ReadToEndAsync();

Console.WriteLine($"Read {counting.BytesRead:N0} bytes from disk.");
// Our decorator tracked every byte, but the rest of the code
// didn't need to know it was there — it's just another Stream.

// The layer order:
//   reader → buffered → counting → file
//   StreamReader reads from BufferedStream
//   BufferedStream reads from CountingStream (our decorator counts bytes)
//   CountingStream reads from FileStream (actual disk I/O)

The first two approaches are great for understanding Decorator, but in real ASP.NET Core projects you don't manually nest new SmsDecorator(new EmailNotifier()). That would scatter new calls throughout your codebase and make it hard to manage decorator chains. Instead, you let the DI containerThe dependency injection container (IServiceProvider) built into ASP.NET Core. It creates objects, manages their lifetimes (transient, scoped, singleton), and automatically resolves constructor dependencies. When you register a decorator, the container handles the nesting for you. handle the wrapping. ScrutorA NuGet package that extends Microsoft.Extensions.DependencyInjection with assembly scanning and service decoration. Install with: dotnet add package Scrutor. Its Decorate method automatically wraps registered services — the container handles the nesting for you. is a popular library that adds a Decorate() method to the built-in DI container.

LoggingNotifierDecorator.cs
// A decorator that logs before and after sending
public class LoggingNotifierDecorator : INotifier
{
    private readonly INotifier _inner;
    private readonly ILogger<LoggingNotifierDecorator> _logger;

    public LoggingNotifierDecorator(INotifier inner, ILogger<LoggingNotifierDecorator> logger)
    {
        _inner  = inner;
        _logger = logger;
    }

    public void Send(string message)
    {
        _logger.LogInformation("Sending notification: {Message}", message);
        var sw = Stopwatch.StartNew();

        _inner.Send(message);   // delegate to the real notifier

        sw.Stop();
        _logger.LogInformation("Notification sent in {Elapsed}ms", sw.ElapsedMilliseconds);
    }
}
Program.cs — DI registration with Scrutor
var builder = WebApplication.CreateBuilder(args);

// 1. Register the real implementation
builder.Services.AddScoped<INotifier, EmailNotifier>();

// 2. Wrap it with decorators — order matters! (outermost = last registered)
builder.Services.Decorate<INotifier, SmsDecorator>();
builder.Services.Decorate<INotifier, LoggingNotifierDecorator>();

// Now when any class asks for INotifier via DI, it gets:
//   LoggingDecorator → SmsDecorator → EmailNotifier
// The container handles all the nesting automatically!

var app = builder.Build();

// In a controller or service:
public class AlertController(INotifier notifier) : ControllerBase
{
    [HttpPost("alert")]
    public IActionResult SendAlert(string message)
    {
        notifier.Send(message);  // calls the full decorator chain
        return Ok();
    }
}
Why Scrutor?

Microsoft's built-in DI container doesn't have a Decorate() method out of the box. You could wire decorators manually with factory lambdas like this:

Manual decoration without Scrutor (verbose)
// Without Scrutor — you have to do it manually
builder.Services.AddScoped<INotifier>(sp =>
{
    var email = new EmailNotifier();
    var sms = new SmsDecorator(email);
    var logger = sp.GetRequiredService<ILogger<LoggingNotifierDecorator>>();
    return new LoggingNotifierDecorator(sms, logger);
});
// This works, but it's fragile, hard to read, and breaks if you change the chain.

Scrutor's one-liner approach is much cleaner and is the de facto standard in the .NET ecosystem. Alternatively, .NET 8's Keyed ServicesA new feature in .NET 8 that lets you register multiple implementations of the same interface, each with a unique key. You can use this to manually wire decorator chains by resolving the "inner" service by key and wrapping it. It's built-in (no Scrutor needed) but more verbose. can achieve similar results without a third-party package.

Which Approach Should You Use?

Approach Best For Pros Cons
Classic GoF Learning, libraries, non-DI projects Clear structure, easy to understand, no dependencies Manual nesting, verbose for large chains
Stream-Style I/O operations, custom stream processing Framework-native, zero packages, works with all Stream APIs Many methods to override, only works for Stream-based scenarios
DI + Scrutor ASP.NET Core apps, production services One-liner registration, automatic nesting, clean Program.cs Third-party dependency (or verbose Keyed Services)

For most ASP.NET Core applications, the DI + Scrutor approach is the way to go. For libraries or non-DI code, the Classic GoF pattern is ideal. The Stream-Style is specific to I/O scenarios where you need to work with the Stream API.

Section 6

Jr vs Sr Implementation

This section shows how the same problem gets solved very differently by a junior and a senior developer. The junior reaches for inheritance (make a class for each combination), while the senior reaches for composition (make a class for each feature and wrap them). The difference is dramatic — and it's the whole reason Decorator exists.

Problem Statement

Build a text formatter that supports bold, italic, underline, and color formatting — in any combination. A user should be able to apply any subset of these formats, in any order. Think of it like a word processor's formatting toolbar — click Bold, click Italic, and the text gets both. The system must handle all 16 possible combinations of these 4 formats.

How a Junior Thinks

"I'll just make a class for each combination. BoldText, ItalicText, BoldItalicText... how hard can it be?"

This is the most natural first instinct — and it works... until it doesn't. Let's see what happens when you follow this approach with just 4 formatting options.

JuniorFormatter.cs — The class explosion
// One class per combination... 4 formats = 2⁴ = 16 possible combinations!

public class PlainText     { public string Format(string t) => t; }
public class BoldText      { public string Format(string t) => $"<b>{t}</b>"; }
public class ItalicText    { public string Format(string t) => $"<i>{t}</i>"; }
public class UnderlineText { public string Format(string t) => $"<u>{t}</u>"; }
public class ColorText     { public string Format(string t) => $"<span style='color:red'>{t}</span>"; }

// Now the combos start...
public class BoldItalicText         { public string Format(string t) => $"<b><i>{t}</i></b>"; }
public class BoldUnderlineText      { public string Format(string t) => $"<b><u>{t}</u></b>"; }
public class BoldColorText          { public string Format(string t) => $"<b><span style='color:red'>{t}</span></b>"; }
public class ItalicUnderlineText    { public string Format(string t) => $"<i><u>{t}</u></i>"; }
public class ItalicColorText        { public string Format(string t) => $"<i><span style='color:red'>{t}</span></i>"; }
public class BoldItalicUnderlineText { public string Format(string t) => $"<b><i><u>{t}</u></i></b>"; }
// ... 5 more classes ... and we only have 4 formats!
// Add a 5th format (strikethrough) and it's 32 classes. A 6th = 64. 😱

Problems

This approach works for a small number of formats, but it breaks down fast. Here are the three big problems that make it unmaintainable in production.

Combinatorial Explosion

With N formatting options, you need 2NExponential growth. If you have 4 options (each either on or off), there are 2x2x2x2 = 16 possible combinations. Add a 5th option: 32. A 6th: 64. This is why the "one class per combination" approach collapses under its own weight — the Decorator pattern reduces it to just N classes. classes to cover every combination. 4 formats = 16 classes. 6 formats = 64 classes. This grows exponentially and is completely unmaintainable.

Can't Add New Formats Without N New Classes

Want to add "strikethrough"? You now need to create StrikethroughText, BoldStrikethroughText, ItalicStrikethroughText, BoldItalicStrikethroughText... and every other combination with the new format. One feature = a cascade of new classes.

Rigid Inheritance — No Runtime Flexibility

The formatting choice is baked in at compile time. If a user wants to toggle "bold" on or off at runtime, you'd have to swap the entire object for a different class. There's no way to dynamically add or remove a single format.

How a Senior Thinks

"Each format is an independent behavior. I'll make one small class per format and wrap them around each other. 4 formats = 4 classes, not 16. Adding a 5th? One more class."

The senior realizes that bold, italic, underline, and color are independent features — they don't depend on each other. So instead of hardcoding every combination into a separate class, they create one class per feature that can wrap anything. The combinations happen at runtime, not compile time.

ITextComponent.cs
// The shared contract — anything that can format text
public interface ITextComponent
{
    string Format(string text);
}
PlainText.cs
// The base object — returns text as-is
public class PlainText : ITextComponent
{
    public string Format(string text) => text;
}
BoldDecorator.cs — Same pattern for Italic, Underline, Color
// Each decorator: one class, one responsibility
public class BoldDecorator : ITextComponent
{
    private readonly ITextComponent _inner;
    public BoldDecorator(ITextComponent inner) => _inner = inner;

    public string Format(string text)
        => $"<b>{_inner.Format(text)}</b>";  // wrap the inner result
}

// ItalicDecorator — exact same structure
public class ItalicDecorator : ITextComponent
{
    private readonly ITextComponent _inner;
    public ItalicDecorator(ITextComponent inner) => _inner = inner;
    public string Format(string text) => $"<i>{_inner.Format(text)}</i>";
}

// UnderlineDecorator — exact same structure
public class UnderlineDecorator : ITextComponent
{
    private readonly ITextComponent _inner;
    public UnderlineDecorator(ITextComponent inner) => _inner = inner;
    public string Format(string text) => $"<u>{_inner.Format(text)}</u>";
}

// ColorDecorator — same structure, with an extra parameter
public class ColorDecorator : ITextComponent
{
    private readonly ITextComponent _inner;
    private readonly string _color;
    public ColorDecorator(ITextComponent inner, string color = "red")
    { _inner = inner; _color = color; }
    public string Format(string text)
        => $"<span style='color:{_color}'>{_inner.Format(text)}</span>";
}
Program.cs — Compose any combination at runtime
// 4 formats, 4 classes. NOT 16.
// Any combination, any order — chosen at runtime, not compile time.

ITextComponent text = new PlainText();

// Bold + Italic
text = new BoldDecorator(text);
text = new ItalicDecorator(text);
Console.WriteLine(text.Format("Hello"));
// <i><b>Hello</b></i>

// Bold + Italic + Underline + Color — just keep wrapping
text = new UnderlineDecorator(text);
text = new ColorDecorator(text, "blue");
Console.WriteLine(text.Format("World"));
// <span style='color:blue'><u><i><b>World</b></i></u></span>

// Want to add Strikethrough later? ONE new class:
// public class StrikethroughDecorator : ITextComponent { ... }
// Zero changes to existing code. Open/Closed Principle in action.

Design Decisions

The senior approach uses three key design decisions that make the system maintainable, flexible, and extensible. Each one directly solves a problem from the junior approach.

Single Class Per Format

Each formatting option is its own decorator class. 4 formats = 4 classes. 10 formats = 10 classes. Linear growth instead of exponential. Adding a new format never requires touching existing code.

Compose Freely at Runtime

Because every decorator takes an ITextComponent and IS an ITextComponent, you can stack them in any order, any combination. The choice of which formats to apply can be driven by user preferences, config files, or feature flags — decided at runtime.

Open for Extension, Closed for Modification

Want strikethrough? Create StrikethroughDecorator. Want highlight? Create HighlightDecorator. Zero changes to PlainText, BoldDecorator, or any existing class. The system grows without breaking.

The Bottom Line

Here's the math that makes the case for Decorator:

Formats Junior (one class per combo) Senior (Decorator)
4 (bold, italic, underline, color) 24 = 16 classes 4 classes + 1 interface + 1 base
5 (+ strikethrough) 25 = 32 classes 5 classes (one new decorator)
6 (+ highlight) 26 = 64 classes 6 classes (one new decorator)
10 formats 210 = 1,024 classes 10 classes

The senior approach grows linearly. The junior approach grows exponentially. This is why Decorator exists — it trades an inheritance explosion for simple, composable wrappers.

Section 7

Evolution of Decorator in .NET

The Decorator pattern has been part of .NET since day one — but the way developers use it has evolved dramatically over two decades. Early .NET relied on class inheritance and manual wrapping. Modern .NET makes decoration almost invisible through DI containers, middleware, and functional composition.

What's interesting is that each era didn't replace the previous approach — it added a new option. Stream decorators from 2002 still work perfectly in 2025. The evolution is about having more tools, not replacing old ones. Here's the journey from manual wrapping to one-liner DI decoration.

EraKey FeatureDecorator Impact
.NET 1.0 (2002)Stream class hierarchyEstablished Decorator as a core .NET pattern
.NET 2.0 (2005)GenericsOne decorator class works for any type — no more per-type wrappers
.NET 3.5 (2007)LINQFunctional decoration via method chaining
ASP.NET Core (2016+)Middleware pipelineDecorator becomes the architecture of the entire web framework
.NET 8+ (2023+)Scrutor & Keyed ServicesDI-based decoration with zero manual nesting

.NET 1.0 (2002) — Stream Decorators Set the Standard

From the very first version, .NET shipped with the Stream class hierarchy — the framework's own textbook Decorator. FileStream read raw bytes from disk. BufferedStreamA stream decorator that adds an in-memory buffer. Instead of hitting the disk for every tiny read/write, it collects operations into larger chunks. This dramatically improves performance for code that reads or writes small amounts at a time. wrapped it to add an in-memory buffer. CryptoStreamA stream decorator that encrypts or decrypts data as it flows through. It wraps any stream and uses a cryptographic algorithm (AES, DES, etc.) to transform bytes transparently. The code reading from or writing to the stream doesn't need to know encryption is happening. wrapped that to add encryption. Each one was just a Stream that held another Stream inside it.

This established the pattern in .NET culture, but everything was manual. You had to instantiate each wrapper yourself and nest them by hand in the right order. There were no DI containers, no Decorate() helpers — just raw new calls.

The order of wrapping mattered too. Put CryptoStream on the outside and GZipStream inside? You encrypt compressed data (smaller files, good). Reverse them? You compress encrypted data (encrypted data doesn't compress well, bad). The developer had to know the right order — and there was no framework to enforce it.

.NET 1.0 Stream Wrapping
// Manual nesting — the developer controls every layer
FileStream file = new FileStream("data.dat", FileMode.Open);
BufferedStream buffered = new BufferedStream(file, 4096);
CryptoStream encrypted = new CryptoStream(buffered, aes.CreateDecryptor(), CryptoStreamMode.Read);

// Read from the outermost layer — decryption + buffering happen transparently
byte[] buffer = new byte[1024];
int bytesRead = encrypted.Read(buffer, 0, buffer.Length);
Limitations

No genericsA C# 2.0 feature that lets you write type-safe code that works with any type. Before generics, collections like ArrayList stored everything as 'object', requiring casting. Generics (List<T>, Dictionary<TKey,TValue>) eliminated this — and enabled reusable decorator classes. yet, so decorators for collections or services required casting and boxingThe process of wrapping a value type (int, bool, struct) inside an object on the heap. Before generics, putting an int into an ArrayList boxed it (performance hit). Generic collections eliminated boxing entirely.. No DI framework meant all wiring was manual — you'd write the nesting yourself in every class that needed it. DisposeThe pattern for releasing unmanaged resources (file handles, network connections, database connections) in .NET. When decorators wrap streams, you must dispose from the outside in. Forgetting to dispose an inner stream causes resource leaks — the file stays open, the connection stays alive. chains were tricky — forgetting to dispose an inner stream caused resource leaks that were hard to track down.

GenericsA C# feature that lets you write classes and methods that work with any type, without losing type safety. Instead of writing a decorator for INotifier, another for ILogger, another for IRepository, you write one generic decorator that works with all of them: Decorator<T>. changed everything. Before generics, if you wanted a "logging decorator" you'd write a separate wrapper for each interface. With generics, you could write one decorator that worked for any interface — type-safe, no casting needed.

Generic Decorator with .NET 2.0
// A generic repository decorator — works for any entity type
public interface IRepository<T> where T : class
{
    T? GetById(int id);
    void Add(T entity);
}

// Caching decorator — wraps ANY IRepository<T>
public class CachingRepository<T> : IRepository<T> where T : class
{
    private readonly IRepository<T> _inner;
    private readonly Dictionary<int, T> _cache = new();

    public CachingRepository(IRepository<T> inner) => _inner = inner;

    public T? GetById(int id)
    {
        if (_cache.TryGetValue(id, out var cached)) return cached;
        var entity = _inner.GetById(id);
        if (entity is not null) _cache[id] = entity;
        return entity;
    }

    public void Add(T entity)
    {
        _inner.Add(entity);   // delegate
        // Could also cache here if we had the ID
    }
}
Why This Mattered

One CachingRepository<T> class now worked for Product, Order, User — any entity. Before generics, you'd need CachingProductRepository, CachingOrderRepository, etc. Generics + Decorator = massive reduction in boilerplateRepetitive, formulaic code that you have to write over and over. Before generics, every typed decorator required its own nearly-identical class. Generics let you write the pattern once and reuse it for any type..

LINQLanguage Integrated Query — C#'s built-in way to query and transform collections. Methods like .Where(), .OrderBy(), .Select() are chained together. Each one wraps the previous result in a new layer that adds filtering, sorting, or transformation — which is exactly the Decorator idea, expressed as functions instead of classes. brought a completely different flavor of decoration. Instead of wrapping objects in classes, you chain method calls — each one wraps the result of the previous one with new behavior. It's Decorator applied to data pipelines, with deferred executionLINQ doesn't process data when you chain .Where().OrderBy().Select(). It just builds the decorator pipeline. The actual filtering, sorting, and transforming only happens when you iterate (foreach) or materialize (.ToList()). The chain is set up first, data flows through later. — the pipeline is assembled first, and data flows through only when you iterate.

LINQ — Functional Decoration
// Each method wraps the previous IEnumerable in a new layer
var results = products
    .Where(p => p.Price > 10)         // layer 1: filter
    .OrderBy(p => p.Name)              // layer 2: sort
    .Select(p => new { p.Name, p.Price }) // layer 3: transform
    .Take(5);                           // layer 4: limit

// Under the hood, each method returns a NEW IEnumerable<T>
// that wraps the previous one — exactly like nesting decorators.
// Nothing executes until you iterate (deferred execution).

// The wrapper chain looks like:
//   TakeIterator
//     → SelectIterator
//       → OrderedEnumerable
//         → WhereIterator (price > 10)
//           → original products list
// Each layer IS an IEnumerable<T> and HAS an IEnumerable<T>

ASP.NET Core's middleware pipelineThe sequence of components that handle every HTTP request in ASP.NET Core. Each middleware wraps the next one — a request flows inward through the layers, the innermost handler produces a response, and the response flows back outward. Each layer can inspect, modify, or short-circuit the request. It's the Decorator pattern applied at the web framework level. is the Decorator pattern applied at the framework level. Each Use() call wraps the rest of the pipeline in a new layer. Authentication wraps authorization, which wraps routing, which wraps your endpoint — Russian nesting dolls for HTTP requests.

Program.cs — Middleware as Decorator Chain
var app = builder.Build();

// Each Use/Map call wraps the next layer — outermost first
app.UseExceptionHandler("/error");  // outermost: catches exceptions from everything inside
app.UseHttpsRedirection();          // layer 2: redirects HTTP → HTTPS
app.UseAuthentication();            // layer 3: who are you?
app.UseAuthorization();             // layer 4: are you allowed?
app.UseRateLimiter();               // layer 5: not too fast!
app.MapControllers();               // innermost: the actual endpoint

// Request flows IN:  ExceptionHandler → HTTPS → Auth → Authz → Rate → Controller
// Response flows OUT: Controller → Rate → Authz → Auth → HTTPS → ExceptionHandler
// Exactly like: new ExceptionHandler(new HttpsRedirect(new Auth(new Authz(...))))
Best Practice Today

This is the most important Decorator you'll use in .NET. Understanding middleware as a decorator chain helps you reason about request/response flow, error handling, and the order in which concerns are applied. If you get the order wrong, bugs happen (e.g., authorization before authentication).

Modern .NET makes decoration effortless through DI. ScrutorA NuGet package that adds service decoration and assembly scanning to Microsoft's DI container. Its Decorate<TInterface, TDecorator>() method is a one-liner that wraps any registered service. It's the most popular way to do DI-based decoration in .NET. adds a Decorate() method to the built-in container. .NET 8's Keyed ServicesA built-in .NET 8 feature that lets you register multiple implementations of the same interface with different string keys. You can use this to manually wire decorator chains — register the "real" service with one key, then register the decorator that resolves the keyed service as its inner dependency. let you wire decorator chains without any third-party packages at all.

This era represents the maturity of Decorator in .NET. The pattern itself hasn't changed since 1994 — it's still "wrap an object, add behavior, forward calls." But the tooling around it has evolved to the point where you rarely write the nesting code yourself. The DI container handles it, and your code stays clean and focused on the actual behavior each decorator adds.

Looking Ahead

The next frontier is source generatorsA C# compiler feature that generates code at compile time based on your existing code. Imagine marking an interface with an attribute and having the compiler auto-generate a decorator base class with all methods pre-wired to delegate. Some libraries are already experimenting with this approach. — compile-time code generation that could auto-create decorator base classes from an interface. Some experimental libraries already do this, eliminating even the boilerplate of writing the "forwarding" methods manually. The pattern stays the same; the tooling keeps getting smarter.

.NET 8 — Keyed Services for decoration (no Scrutor)
var builder = WebApplication.CreateBuilder(args);

// .NET 8 Keyed Services — built-in decorator wiring
builder.Services.AddKeyedScoped<INotifier, EmailNotifier>("core");

builder.Services.AddScoped<INotifier>(sp =>
{
    var inner = sp.GetRequiredKeyedService<INotifier>("core");
    var logger = sp.GetRequiredService<ILogger<LoggingNotifierDecorator>>();
    return new LoggingNotifierDecorator(
        new SmsDecorator(inner),
        logger);
});

// Or just use Scrutor — it's cleaner for this:
builder.Services.AddScoped<INotifier, EmailNotifier>();
builder.Services.Decorate<INotifier, SmsDecorator>();
builder.Services.Decorate<INotifier, LoggingNotifierDecorator>();
Section 8

Decorator in the .NET Framework

If you've written any .NET code, you've already used the Decorator pattern — even if you didn't know it had a name. The framework is packed with decorators: streams that wrap streams, HTTP handlers that wrap handlers, middleware that wraps middleware.

Recognizing Decorator in the wild is a valuable skill. The telltale sign: a class that implements the same interface (or extends the same base class) as the thing it wraps, takes one through its constructor, and forwards most calls while adding behavior to a few. Once you see the pattern, you'll spot it everywhere in the .NET ecosystem.

The diagram below shows the three most important categories of Decorator in .NET, with specific classes in each. The leftmost column shows the base object at the top and decorators wrapping it below — exactly the nesting pattern we've been discussing.

Decorator in .NET — three categories: Stream Wrappers, HTTP Pipeline, and Middleware STREAM WRAPPERS FileStream wrapped by BufferedStream wrapped by GZipStream wrapped by CryptoStream HTTP PIPELINE HttpClientHandler wrapped by LoggingHandler wrapped by RetryHandler wrapped by AuthHandler MIDDLEWARE MapControllers wrapped by UseAuthorization wrapped by UseAuthentication wrapped by UseExceptionHandler

Let's dig into each of these in detail. Click any card to expand it and see real code examples showing how the framework uses the Decorator pattern internally.

CategoryBase (Component)Example DecoratorsWhat They Add
Stream Wrappers Stream BufferedStream, GZipStream, CryptoStream Buffering, compression, encryption
HTTP Pipeline HttpMessageHandler DelegatingHandler subclasses Retry, logging, auth headers, timing
Middleware RequestDelegate UseAuthentication(), UseAuthorization(), custom middleware Auth, CORS, exception handling, rate limiting
Logging ILogger<T> Custom ILogger wrappers Correlation IDs, enrichment, filtering
LINQ IEnumerable<T> .Where(), .OrderBy(), .Select() Filtering, sorting, transformation, limiting

The Stream hierarchy is the most classic Decorator in all of .NET. The base class Stream is the ComponentIn Decorator terms, the Component is the shared interface that both the real object and all wrappers implement. For streams, the Component is the abstract Stream class — FileStream, BufferedStream, GZipStream, and CryptoStream all extend it.. FileStream and MemoryStream are concrete components that do the actual I/O. Everything else — BufferedStream, GZipStream, CryptoStream, DeflateStream, BrotliStream — are decorators that wrap another stream and add one responsibility.

The beauty is that any code expecting a Stream works with the whole stack. A StreamReader doesn't know or care how many decorator layers it's reading through. You could have zero layers (reading directly from FileStream) or five layers (buffering + compression + encryption + rate limiting + counting) — the StreamReader code stays exactly the same.

Here's a complete example showing both read and write scenarios with multiple decorator layers. Pay attention to the nesting — each using variable wraps the previous one.

Stream Decorator Stack
// Read a compressed, encrypted file — each layer is a decorator
await using var file       = new FileStream("secrets.dat", FileMode.Open);
await using var buffered   = new BufferedStream(file, 8192);        // adds buffering
await using var decompress = new GZipStream(buffered,               // adds decompression
                                  CompressionMode.Decompress);
await using var decrypt    = new CryptoStream(decompress,           // adds decryption
                                  aes.CreateDecryptor(),
                                  CryptoStreamMode.Read);
await using var reader     = new StreamReader(decrypt, Encoding.UTF8);

// This single call flows through 4 layers:
// reader → decrypt → decompress → buffered → file
string content = await reader.ReadToEndAsync();

// Write the same way — just reverse the direction:
await using var outFile     = new FileStream("backup.dat", FileMode.Create);
await using var outBuffered = new BufferedStream(outFile);
await using var compress    = new GZipStream(outBuffered, CompressionLevel.Optimal);
await using var encrypt     = new CryptoStream(compress,
                                   aes.CreateEncryptor(),
                                   CryptoStreamMode.Write);
await using var writer      = new StreamWriter(encrypt);
await writer.WriteLineAsync("Sensitive data goes here");

When you use HttpClient, every HTTP request passes through a chain of DelegatingHandlerAn abstract class in System.Net.Http that holds a reference to an "inner handler." You override SendAsync, do your thing (log, retry, add headers), then call base.SendAsync to pass the request to the next handler in the chain. It's the Decorator pattern applied to HTTP calls. objects. Each handler wraps the next one. The innermost handlerHttpClientHandler — the only handler in the chain that actually opens a TCP connection and sends bytes over the network. Every other handler in the chain is a decorator that adds behavior (logging, retries, auth headers) before and after the real network call. (HttpClientHandler) actually talks to the network. Every handler above it is a decorator that adds cross-cutting behavior.

This is incredibly powerful for building resilient HTTP clients. Need retries? Add a RetryHandler. Need request timing? Add a TimingHandler. Need auth tokens? Add an AuthHandler. Each one is a single class that does one thing — and they compose together into a robust pipeline. Libraries like PollyA popular .NET resilience and transient-fault-handling library. It provides pre-built policies for retry, circuit breaker, timeout, and bulkhead isolation — all implemented as DelegatingHandler decorators that you can stack onto your HttpClient pipeline. use this pattern extensively for retry policies and circuit breakers.

Custom DelegatingHandler — Retry + Timing
// A retry decorator for HTTP requests (GET/HEAD only — safe to retry)
public class RetryHandler : DelegatingHandler
{
    private readonly int _maxRetries;
    private static readonly HashSet<int> TransientCodes = [408, 429, 500, 502, 503, 504];

    public RetryHandler(int maxRetries = 3) => _maxRetries = maxRetries;

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        for (int i = 0; i <= _maxRetries; i++)
        {
            var response = await base.SendAsync(request, ct);  // delegate to inner

            // Only retry on transient failures (5xx, 408, 429)
            bool isTransient = TransientCodes.Contains((int)response.StatusCode);
            if (response.IsSuccessStatusCode || !isTransient || i == _maxRetries)
                return response;

            await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, i)), ct);
        }
        throw new UnreachableException();   // compiler satisfaction
    }
}

// A timing decorator — logs how long each request takes
public class TimingHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var sw = Stopwatch.StartNew();
        var response = await base.SendAsync(request, ct);      // delegate to inner
        sw.Stop();
        Console.WriteLine($"{request.Method} {request.RequestUri} — {sw.ElapsedMilliseconds}ms");
        return response;
    }
}

// Wire the chain — outermost handler first
var handler = new TimingHandler
{
    InnerHandler = new RetryHandler(maxRetries: 2)
    {
        InnerHandler = new HttpClientHandler()   // the real network call
    }
};
var client = new HttpClient(handler);

Every app.Use() call in your ASP.NET Core startup is adding a decorator around the request pipeline. Each middleware receives the request, can modify it, calls next() to pass it to the inner layer, then can modify the response on the way back out. This is exactly the Decorator pattern — but for HTTP request/response processing.

The order you register middleware matters enormously. UseAuthentication() must come before UseAuthorization(), because you need to know who the user is before you can check what they're allowed to do. Getting the order wrong is one of the most common bugs in ASP.NET Core apps — we'll cover this exact mistake in the Bug Case Studies section later.

The key thing to notice: each middleware doesn't know what comes before or after it in the chain. It just knows about _next — the rest of the pipeline. That's the Decorator principle at work: each layer is independent and only knows about the layer it wraps.

Custom Middleware — Request Timing Decorator
// A custom middleware — decorates the pipeline with timing
public class RequestTimingMiddleware
{
    private readonly RequestDelegate _next;   // the "inner" — rest of pipeline
    private readonly ILogger<RequestTimingMiddleware> _logger;

    public RequestTimingMiddleware(RequestDelegate next, ILogger<RequestTimingMiddleware> logger)
    {
        _next   = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        var sw = Stopwatch.StartNew();

        await _next(context);                 // delegate to inner pipeline

        sw.Stop();
        _logger.LogInformation(
            "{Method} {Path} completed in {Elapsed}ms",
            context.Request.Method,
            context.Request.Path,
            sw.ElapsedMilliseconds);
    }
}

// Register it — wraps everything that comes after it
app.UseMiddleware<RequestTimingMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

Want to add extra context to every log message — like a correlation IDA unique identifier that follows a request through all the services it touches. When something goes wrong, you search your logs by correlation ID and see the complete story — what happened in the API, the database, the queue, everything. It's essential for debugging distributed systems., the current user, or the request path? You can decorate ILogger to enrich log entries without changing any of your existing logging code. The decorator wraps the real logger and adds information before forwarding the call.

This approach is far better than modifying every log statement in your codebase. Instead of adding correlationId to hundreds of log calls, you wrap the logger once — and every log message from that point automatically includes the context. You could also chain multiple logger decorators: one for correlation IDs, another for request timing, another for user identity — each adding its own context to every log entry.

CorrelationIdLogger.cs — Decorating ILogger
// Decorates any ILogger to prepend a correlation ID to every message
public class CorrelationIdLogger<T> : ILogger<T>
{
    private readonly ILogger<T> _inner;
    private readonly IHttpContextAccessor _httpContext;

    public CorrelationIdLogger(ILogger<T> inner, IHttpContextAccessor httpContext)
    {
        _inner = inner;
        _httpContext = httpContext;
    }

    public void Log<TState>(LogLevel logLevel, EventId eventId,
        TState state, Exception? exception, Func<TState, Exception?, string> formatter)
    {
        // Add correlation ID from the HTTP request header
        var correlationId = _httpContext.HttpContext?
            .Request.Headers["X-Correlation-ID"]
            .FirstOrDefault() ?? Guid.NewGuid().ToString("N")[..8];

        // Create enriched scope, then delegate to the real logger
        using (_inner.BeginScope(new Dictionary<string, object>
        {
            ["CorrelationId"] = correlationId
        }))
        {
            _inner.Log(logLevel, eventId, state, exception, formatter);
        }
    }

    public bool IsEnabled(LogLevel logLevel) => _inner.IsEnabled(logLevel);
    public IDisposable? BeginScope<TState>(TState state) where TState : notnull
        => _inner.BeginScope(state);
}

LINQ is Decorator expressed as functional compositionBuilding complex behavior by chaining simple functions together, where each function's output becomes the next function's input. In LINQ, .Where() returns a new IEnumerable that wraps the original with filtering. .OrderBy() wraps that with sorting. Each method "decorates" the previous result with one additional behavior.. Each LINQ method — .Where(), .OrderBy(), .Select(), .Take() — returns a new IEnumerable<T> that wraps the previous one with additional behavior. The original collection is never modified.

You might not think of LINQ as "the Decorator pattern" — and that's fine. The point is that the idea of wrapping one thing inside another to add behavior is so fundamental that it shows up in class-based design (Stream), request pipelines (middleware), HTTP calls (DelegatingHandler), AND functional data processing (LINQ). Same idea, different forms.

Recognizing the Pattern

Whenever you see a method that returns the same type as its input — like .Where() returning IEnumerable<T> from an IEnumerable<T> — you're looking at functional decoration. The method "wraps" the input with new behavior and returns a compatible object. This enables the fluent chaining syntax that makes LINQ so readable.

LINQ as Functional Decoration
// Each chained method wraps the previous IEnumerable with new behavior
var report = orders
    .Where(o => o.Status == "Completed")        // decorator 1: filter
    .Where(o => o.Total > 100m)                  // decorator 2: another filter
    .OrderByDescending(o => o.Total)              // decorator 3: sort
    .Select(o => new                              // decorator 4: transform shape
    {
        o.Id,
        o.CustomerName,
        FormattedTotal = o.Total.ToString("C")
    })
    .Take(20);                                     // decorator 5: limit count

// Under the hood, this is a chain of wrapper objects:
// TakeIterator → SelectIterator → OrderedEnumerable → WhereIterator → WhereIterator → orders
// Each one IS an IEnumerable and HAS an IEnumerable — classic Decorator structure.

// Nothing executes until you iterate (deferred execution).
// The decoration just sets up the pipeline — data flows when you foreach or ToList().
foreach (var item in report)
{
    Console.WriteLine($"{item.Id}: {item.CustomerName} — {item.FormattedTotal}");
}
How to Spot Decorator in the Wild

Look for these telltale signs in any .NET codebase:

1. Constructor that takes its own type: A class that implements INotifier and takes an INotifier in its constructor — that's a decorator wrapping the inner object.

2. Method that mostly delegates: If a method's body is mostly calling the same method on an inner object, with some added behavior before or after — that's decorator delegation.

3. Chain of Use() or Add() calls: Any API where you chain .Use(), .Add(), or similar builder methods is likely building a decorator pipeline under the hood.

4. "Wrapper" or "Handler" in the class name: Names like LoggingHandler, CachingWrapper, RetryDecorator, BufferedStream — the naming convention often reveals the pattern.

Section 9

When To Use / When Not To

Use When

You need to add behavior without touching existing code — the original class works fine, you just want to layer something on top (logging, caching, validation)
You have shared behaviors (logging, retry, caching, authorization) that need to be applied consistently across multiple services — these are called cross-cutting concerns, and decorators let you add them without copy-pasting logic everywhere
You're working with stream wrapping — .NET's Stream class is the textbook Decorator. BufferedStream wraps FileStream wraps GZipStream — each layer adds one feature
You want to combine behaviors dynamically at runtime — sometimes you need logging + caching, sometimes just caching, sometimes nothing. Decorators let you compose what you need
There are many optional behaviors for the same interface — without Decorator you'd face a class explosion: LoggingCachingRetryService, LoggingRetryService, CachingService, etc.

Don't Use When

You only have 1–2 fixed behaviors that never change — wrapping for the sake of wrapping is over-engineering. Just put the logic in the class
The behavior is permanent and essential to the class — if every order service ALWAYS logs, that's just part of the class. Use inheritance or put it inline
You need to access private members of the original class — decorators only see the public interface. If you need internals, you need inheritance or a different approach
The order of decoration is critical and fragile — if swapping two decorators causes bugs, the design is too tightly coupled. Consider a Pipeline patternA Pipeline (or Chain of Responsibility) processes steps in a fixed, explicit order. Unlike Decorator where wrapping order can be swapped freely, a Pipeline makes the sequence obvious and configurable. Use it when step order matters and must be visible in the code. with explicit ordering instead
You're in a performance-critical hot path called millions of times per second — each decorator layer adds a virtual method call and object allocation. Profile before decorating hot paths

Decision Framework

Need to add behavior to existing objects? NO No Decorator needed YES Can you modify the original class? YES Just modify it directly NO Multiple optional behaviors? NO Simple wrapper or Proxy YES Use Decorator ✓
Section 10

Comparisons

Decorator looks similar to a few other patterns — they all involve wrapping objects. Here's the key difference in plain terms so you always pick the right one.

Decorator vs Inheritance

Decorator
  • Composes behavior at runtime — stack as many or few features as you need
  • Each decorator does one thing — logging, caching, retry are separate classes
  • N behaviors = N small classes, freely combinable
  • Add new behavior = add one new class, zero changes elsewhere
  • Example: LoggingDecorator(CachingDecorator(realService))
VS
Inheritance
  • Fixes behavior at compile time — you get exactly what the subclass defines
  • Combining features means class explosionLoggingCachingRetryService
  • N behaviors = 2N subclass combinations
  • Adding one new behavior = new subclasses for every combination
  • Rigid: can't swap logging on/off without different subclass

Decorator vs Proxy

Decorator
  • Purpose: add new behavior to an object
  • Multiple decorators can stack — composable
  • Client knows it's using decorators (they're wired explicitly)
  • Enhances what the object does
  • Example: BufferedStream adds buffering to any stream
VS
ProxyProxy provides a surrogate or placeholder for another object. Unlike Decorator (which adds features), Proxy controls access — lazy loading, caching, access control, remote calls. The client usually doesn't know it's talking to a proxy.
  • Purpose: control access to an object
  • Typically a single proxy layer — not designed to stack
  • Client often doesn't know it's using a proxy (transparent)
  • Controls whether/when the object is accessed
  • Example: lazy-loading proxy creates the real object only when first used

Decorator vs Chain of Responsibility

Decorator
  • Always forwards to the wrapped object — every layer runs
  • Enhances the response: each layer adds its own twist
  • All decorators in the chain execute, guaranteed
  • Order matters for layering but every layer participates
  • Example: logging + caching + retry — all three always fire
VS
Chain of ResponsibilityChain of Responsibility passes a request along a chain of handlers. Each handler decides: handle it myself, or pass it to the next handler. Unlike Decorator (everyone participates), CoR can short-circuit — one handler handles the request and the rest never see it. ASP.NET middleware is a mix of both patterns.
  • Can stop propagation — one handler can swallow the request
  • Finds the right handler: only one (or none) actually processes the request
  • Handlers may or may not forward to the next — it's a choice
  • Order matters because first match wins
  • Example: exception handler chain — first handler that recognizes the error handles it

Decorator vs Strategy

Decorator
  • Enhances existing behavior incrementally — adds to what's already there
  • Multiple decorators stack — each adds a thin layer
  • The original behavior always runs (decorator wraps it)
  • Same interface in, same interface out, with extras
  • Example: add logging around the existing order service
VS
StrategyStrategy defines a family of interchangeable algorithms. You swap the ENTIRE behavior at runtime. Unlike Decorator (which wraps and enhances), Strategy replaces the implementation completely. Use Strategy when you need a different algorithm; use Decorator when you need to add features around the same algorithm.
  • Replaces the entire algorithm — swaps one implementation for another
  • One strategy at a time — they don't stack
  • The old behavior is gone; the new one takes over completely
  • Same interface but totally different internals
  • Example: swap credit card payment for PayPal payment
Quick rule: Decorator = add extras around existing behavior. Proxy = control access. Chain of Responsibility = find the right handler (can stop). Strategy = replace the whole algorithm. Inheritance = rigid, compile-time only.
Section 11

SOLID Mapping

PrincipleRelationExplanation
SRPSingle Responsibility Principle — A class should have only one reason to change. Each class should do one thing and do it well. Supports Each decorator handles exactly one concern. LoggingDecorator logs. CachingDecorator caches. RetryDecorator retries. The real service just does business logic. Nobody does two things.
OCPOpen/Closed Principle — Software entities should be open for extension but closed for modification. Add new behavior without changing existing code. Supports Want to add rate-limiting next year? Write a new RateLimitingDecorator, wrap it around the service. Zero changes to the existing service or other decorators. Open for extension, closed for modification — that's exactly what Decorator gives you.
LSPLiskov Substitution Principle — Subtypes must be substitutable for their base types without altering correctness. Supports Every decorator implements the same interface as the component it wraps. The client code says IOrderService and doesn't care if it's the real service, a logging decorator, or five decorators stacked. They're all substitutable.
ISPInterface Segregation Principle — No client should be forced to depend on methods it doesn't use. Prefer small, focused interfaces. Depends Decorator works best with small, focused interfaces. If IOrderService has 20 methods, your decorator must forward all 20 — painful boilerplate. With a slim 2-3 method interface, decorators are a joy to write. Fat interfaces make Decorator painful.
DIPDependency Inversion Principle — High-level modules should not depend on low-level modules. Both should depend on abstractions. Supports Both the decorator and the client depend on the component abstraction (the interface), never on concrete classes. The decorator wraps INotificationService, not SmtpNotificationService. Pure dependency inversion.
Section 12

Bug Case Studies

The Incident

Notification microservice. The team has an INotifier interface with three methods: Send() for synchronous delivery, SendAsync() for background delivery, and GetStatus() for checking whether a notification was delivered.

A developer gets a task: "Add logging to all notifications." They create a LoggingNotifierDecorator that wraps the real notifier. They carefully add logging to the Send() method — it works great, messages are logged, tests pass. Ship it.

Two days later, QA flags a mystery: the notification status dashboard shows every notification as "unknown." The async background notifications are also silently failing — jobs are queued but never actually delivered. The decorator compiled fine, all unit tests passed (they only tested Send()), and no exceptions were thrown anywhere.

The root cause? The developer only overrode Send(). For SendAsync() and GetStatus(), they wrote quick placeholder implementations just to satisfy the compiler: return Task.CompletedTask and return default. These placeholders don't forward to the real notifier — they just return empty values and pretend everything is fine.

What Went Wrong

Think of it like a mail forwarding service. You hired someone to forward all your mail, but they only forward letters — they throw away packages and postcards. That's exactly what this decorator does: it forwards Send() calls but silently drops SendAsync() and GetStatus().

Caller Send() SendAsync() GetStatus() LoggingDecorator ✅ Logs + forwards ❌ Returns empty Task ❌ Returns default Real Notifier ✅ Receives call Never called! Never called!

Time to Diagnose

2 days — the decorator compiled fine (it implemented the interface with default returns). Nobody noticed until QA flagged the status dashboard showing blanks. The sneaky part: no exceptions were thrown. The decorator silently returned empty values instead of failing loudly.

BuggyDecorator.cs
public interface INotifier
{
    void Send(string message);
    Task SendAsync(string message);
    NotificationStatus GetStatus(Guid id);
}

// ❌ Only overrides Send — forgets the other two methods
public class LoggingNotifierDecorator : INotifier
{
    private readonly INotifier _inner;
    private readonly ILogger _logger;

    public LoggingNotifierDecorator(INotifier inner, ILogger logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public void Send(string message)
    {
        _logger.LogInformation("Sending: {Msg}", message);
        _inner.Send(message); // ✅ forwards correctly
    }

    // ❌ These just satisfy the compiler — they don't forward!
    public Task SendAsync(string message) => Task.CompletedTask;
    public NotificationStatus GetStatus(Guid id) => default;
}

Walking through the buggy code: Look at the last two lines. SendAsync() returns Task.CompletedTask — that means "I'm done, everything went fine!" But it never called _inner.SendAsync(), so the actual notification was never sent. Same with GetStatus() — it returns default (which is null for a reference type), so the dashboard shows "unknown" for every notification. The developer wrote these lines just to make the compiler happy, not realizing they were silently swallowing real functionality.

FixedDecorator.cs
// ✅ Step 1: Create a base decorator that forwards EVERYTHING
public abstract class NotifierDecoratorBase : INotifier
{
    protected readonly INotifier Inner;
    protected NotifierDecoratorBase(INotifier inner) => Inner = inner;

    public virtual void Send(string message) => Inner.Send(message);
    public virtual Task SendAsync(string message) => Inner.SendAsync(message);
    public virtual NotificationStatus GetStatus(Guid id) => Inner.GetStatus(id);
}

// ✅ Step 2: Override only what you need — rest auto-forwards
public class LoggingNotifierDecorator : NotifierDecoratorBase
{
    private readonly ILogger _logger;

    public LoggingNotifierDecorator(INotifier inner, ILogger logger)
        : base(inner) => _logger = logger;

    public override void Send(string message)
    {
        _logger.LogInformation("Sending: {Msg}", message);
        base.Send(message); // ✅ delegates to base which forwards
    }

    public override async Task SendAsync(string message)
    {
        _logger.LogInformation("SendAsync: {Msg}", message);
        await base.SendAsync(message); // ✅ async forwarded too
    }
    // GetStatus auto-forwards via base — no code needed
}

Why the fix works: The trick is a base decorator class that sits between the interface and your concrete decorators. This base class has one job: forward every single method to the inner component. It marks each method as virtual, so concrete decorators (like LoggingNotifierDecorator) can override only the methods they care about. Everything else automatically passes through. If the interface gets a new method tomorrow, you add it once in the base class — all decorators inherit the forwarding for free.

Lesson Learned

Always create a base decorator class that forwards every interface method by default. Concrete decorators override only the methods they enhance. This guarantees nothing gets silently swallowed.

How to Spot This in Your Code

If your decorator implements an interface directly (not through a base class), search for methods that return default, null, Task.CompletedTask, or throw NotImplementedException — those are likely methods you forgot to forward.

The Incident

E-commerce order service. The team uses Scrutor's services.Decorate<>() to wrap IOrderService with a logging decorator. It works perfectly — every order placement, cancellation, and status check gets logged.

A week later, a second developer on the same team gets a similar task: "Make sure all order operations are logged." They don't realize logging is already wired up (it's buried in a 200-line Program.cs). They add the exact same line: services.Decorate<IOrderService, LoggingOrderDecorator>(). Both PRs pass code review — neither reviewer scrolled far enough to spot the duplicate.

After the merge, every order operation is now logged twice. The app works perfectly fine — orders go through, customers are happy. But behind the scenes, log storage costs have doubled. Nobody notices for a full week until DevOps flags an unexpected spike in log ingestion billing.

What Went Wrong

Imagine you asked two different assistants to photocopy every document that comes in. Neither assistant knows about the other. Now every document gets photocopied twice — double the paper, double the ink, same result. That's what happened here: two identical decorator layers were wrapped around the same service.

LoggingDecorator #2 (duplicate!) 📝 Logs "PlaceOrder called" LoggingDecorator #1 (original) 📝 Logs "PlaceOrder called" (again!) OrderService = 2× log entries per call

Time to Diagnose

1 week — only discovered when DevOps flagged unexpectedly high log ingestion costs. Tracing showed duplicate log entries with identical timestamps. The tricky part: the app behaved correctly in every way — the only symptom was doubled logs, which is easy to miss when you're not actively watching log volume.

BuggyRegistration.cs
// Program.cs — two separate PRs both added logging
services.AddScoped<IOrderService, OrderService>();

// PR #1 (Alice, March 5)
services.Decorate<IOrderService, LoggingOrderDecorator>(); // ❌

// PR #2 (Bob, March 8) — didn't notice Alice's line
services.Decorate<IOrderService, LoggingOrderDecorator>(); // ❌ duplicate!

// Result: LoggingDecorator(LoggingDecorator(OrderService))
// Every method call logged TWICE

Walking through the buggy code: Scrutor's Decorate<>() method works by replacing the existing service registration with a new one that wraps it. When called twice with the same decorator, it wraps the already-wrapped service again. The DI container doesn't check "is this decorator already applied?" — it blindly stacks another layer. The result is LoggingDecorator(LoggingDecorator(OrderService)), a chain where the same logging happens at two levels.

FixedRegistration.cs
// ✅ Option 1: Extension method that guards against duplicates
public static class DecoratorGuard
{
    public static IServiceCollection DecorateSafe<TService, TDecorator>(
        this IServiceCollection services)
        where TDecorator : TService
    {
        // Check if this decorator type is already wrapping TService
        var decoratorType = typeof(TDecorator);
        bool alreadyRegistered = services.Any(d =>
            d.ServiceType == typeof(TService) &&
            d.ImplementationType == decoratorType);

        if (alreadyRegistered)
            return services;  // skip — already decorated

        return services.Decorate<TService, TDecorator>();
    }
}

// Usage — safe even if called twice
services.DecorateSafe<IOrderService, LoggingOrderDecorator>();
services.DecorateSafe<IOrderService, LoggingOrderDecorator>(); // ✅ silently skipped

// ✅ Option 2: Integration test that catches duplicates
[Fact]
public void NoDuplicateDecorators()
{
    var services = new ServiceCollection();
    ConfigureServices(services); // your DI setup
    var decoratorTypes = services
        .Where(s => s.ServiceType == typeof(IOrderService))
        .Select(s => s.ImplementationType)
        .ToList();
    Assert.Equal(decoratorTypes.Distinct(), decoratorTypes);
}

Why the fix works: Option 1 creates a DecorateSafe extension method that checks the service collection before adding a decorator. It scans for any existing registration where the same decorator type is already wrapping the same service. If found, it silently skips. Option 2 is a safety net — an integration test that builds your entire DI container and asserts that no service has duplicate decorator types. This catches the bug at test time, not in production billing.

Lesson Learned

Decorator registration in DI containers is invisible at the usage site. Two people can both add the same decorator in separate PRs and neither will notice during code review. The Program.cs file doesn't scream "this decorator is already here!" — it's just another line among hundreds.

How to Spot This in Your Code

If you see unexpectedly high log volume, duplicate database calls, or any side-effect happening more than once — check your DI registration for duplicate Decorate<>() calls. Search your Program.cs for the decorator type name and see if it appears more than once.

The Incident

API gateway with caching and logging. The team wraps their IProductService with two decorators: a CachingDecorator (caches responses for 5 minutes) and a LoggingDecorator (logs every method call). Both work individually. But when stacked together, something strange happens.

After deploying, the monitoring dashboard shows that API traffic has dropped 90%. The team panics — did the CDN misconfigure? Did DNS fail? They spend hours checking infrastructure. Traffic hasn't actually dropped at all. 90% of requests are returning from cache, and those cached responses never pass through the logging decorator. The logger only sees the 10% that are cache misses.

The problem: the CachingDecorator is the outermost layer. When it finds a cached response, it returns immediately — the request never reaches the LoggingDecorator sitting underneath. It's like putting a security camera inside a locked room instead of at the entrance — you only see people who make it past the lock.

What Went Wrong

❌ Wrong Order CachingDecorator (outer) Cache hit? → Return immediately Logger NEVER sees this request LoggingDecorator (inner) Only sees cache MISSES (10%) ProductService 📊 Dashboard shows 10% traffic ✅ Correct Order LoggingDecorator (outer) Logs EVERY request first Then forwards to caching layer CachingDecorator (inner) Cache hit → return, miss → forward ProductService 📊 Dashboard shows 100% traffic

Time to Diagnose

3 hours — the metrics showed a massive traffic drop. The team checked CDN, DNS, load balancers, and health endpoints before someone thought to add a breakpoint in the logging decorator and realized it wasn't being called for most requests.

WrongOrder.cs
// ❌ Wrong order: Caching is outermost, Logging is inner
// Decoration order: last registered = outermost
services.AddScoped<IProductService, ProductService>();
services.Decorate<IProductService, LoggingDecorator>();  // inner
services.Decorate<IProductService, CachingDecorator>();  // outer ❌

// Chain: CachingDecorator → LoggingDecorator → ProductService
// Cache hit? CachingDecorator returns immediately
// LoggingDecorator.GetProduct() NEVER called on cache hits
// Result: 90% of traffic is invisible in logs

Walking through the buggy code: In Scrutor, the last Decorate<>() call becomes the outermost wrapper. Here, CachingDecorator is registered last, so it's the first thing that handles every request. When it finds a cached response (90% of the time), it returns immediately — the call never reaches LoggingDecorator sitting inside. The logging layer only runs on the 10% of requests that are cache misses.

CorrectOrder.cs
// ✅ Correct order: Logging is outermost, Caching is inner
services.AddScoped<IProductService, ProductService>();
services.Decorate<IProductService, CachingDecorator>();  // inner — cache
services.Decorate<IProductService, LoggingDecorator>();  // outer — logs ✅

// Chain: LoggingDecorator → CachingDecorator → ProductService
// Every request hits LoggingDecorator first (logs EVERYTHING)
// Then CachingDecorator either returns cached or forwards to service
// Result: all traffic visible, cache hits AND misses logged

// ✅ Rule of thumb for decorator order:
// Outermost: Logging (see everything)
// Next: Retry / Circuit Breaker (resilience)
// Next: Caching (avoid unnecessary work)
// Innermost: Validation (closest to real service)

Why the fix works: By putting LoggingDecorator on the outside (registered last), every request hits logging first. After logging, the request flows inward to CachingDecorator, which either returns a cached response or forwards to the real service. The rule of thumb is simple: decorators that need to see everything go on the outside; decorators that short-circuit go on the inside.

Lesson Learned

Decorator order matters. The outermost decorator runs first. Any decorator that can short-circuit (caching, circuit breaker) will block inner decorators from running. Put observability (logging, metrics) on the outside, and put optimization (caching, throttling) closer to the real service.

How to Spot This in Your Code

If your logs show dramatically less traffic than your load balancer — or if certain decorator behaviors seem to "disappear" — check the registration order in your DI setup. The last Decorate<>() call is the outermost layer.

The Incident

File processing service. The team builds a CompressingStreamDecorator that wraps a FileStream with GZipStream for on-the-fly compression. It works great in testing — files are compressed and written correctly.

In production under load, something weird starts happening: file writes fail with IOException: The process cannot access the file because it is being used by another process. But there is no other process — the file handle was never released from a previous request. The FileStream is sitting in memory, still holding the file lock, waiting for the garbage collector to eventually clean it up.

Under low traffic, the GC runs often enough that handles get cleaned up before they cause problems. Under high load, the GC can't keep up, and file handles pile up like dirty dishes — eventually there's no clean dish (file handle) left and the kitchen (service) shuts down.

What Went Wrong

Think of it like a chain of boxes. You have a small box (FileStream) inside a medium box (GZipStream) inside a big box (your decorator). When someone says "throw everything away," they throw away the big box — but if the big box's disposal fails halfway through, the small box inside is left open on the floor. Nothing cleans it up.

CompressingStreamDecorator.Dispose() GZipStream.Dispose() ⚡ If this throws an exception... FileStream (NOT disposed!) 🔒 File handle still locked 💥 Exception thrown here Inner stream never reached try/finally guarantees inner is always disposed

Time to Diagnose

5 hours — the IOException only appeared under high load when GC couldn't keep up. Process Monitor revealed leaked file handles pointing to "already disposed" streams. The intermittent nature made it especially hard to reproduce locally.

BuggyDispose.cs
public class CompressingStreamDecorator : Stream
{
    private readonly GZipStream _gzip;
    private readonly Stream _innerStream; // FileStream

    public CompressingStreamDecorator(Stream innerStream)
    {
        _innerStream = innerStream;
        _gzip = new GZipStream(innerStream, CompressionMode.Compress);
    }

    // ❌ Dispose only disposes _gzip, but _gzip was created with
    // leaveOpen: false (default) — so it SHOULD dispose inner...
    // EXCEPT this decorator also holds _innerStream reference
    // and might use it after _gzip is disposed!
    protected override void Dispose(bool disposing)
    {
        _gzip.Dispose(); // ❌ disposes _gzip but not explicitly _innerStream
        // ❌ If _gzip.Dispose() throws, _innerStream is leaked
        base.Dispose(disposing);
    }

    // ... Read, Write, etc.
}

Walking through the buggy code: The decorator creates a GZipStream with the default leaveOpen: false, which means GZipStream should dispose the inner stream when it's disposed. But there's a subtle trap: if _gzip.Dispose() throws an exception (e.g., while flushing compressed data), the code jumps past base.Dispose() and the inner FileStream is never explicitly cleaned up. The file handle leaks. Even without the exception, having two things that both think they own the inner stream's lifetime (the GZipStream via leaveOpen: false AND the decorator via _innerStream) is a recipe for confusion.

FixedDispose.cs
public class CompressingStreamDecorator : Stream
{
    private readonly GZipStream _gzip;
    private readonly Stream _innerStream;
    private bool _disposed;

    public CompressingStreamDecorator(Stream innerStream)
    {
        _innerStream = innerStream;
        // ✅ leaveOpen: true — WE manage inner stream lifetime
        _gzip = new GZipStream(innerStream, CompressionMode.Compress,
            leaveOpen: true);
    }

    protected override void Dispose(bool disposing)
    {
        if (!_disposed && disposing)
        {
            _disposed = true;
            try { _gzip.Dispose(); }   // ✅ dispose compression layer
            finally
            {
                _innerStream.Dispose(); // ✅ ALWAYS dispose inner, even if gzip throws
            }
        }
        base.Dispose(disposing);
    }
}

Why the fix works: Two key changes. First, leaveOpen: true tells GZipStream "don't touch the inner stream's lifetime — that's not your job." Now there's exactly one owner of each resource. Second, try/finally guarantees that even if _gzip.Dispose() throws an exception, the finally block will still run and dispose the inner stream. The _disposed flag prevents double-disposal if someone calls Dispose twice (which is allowed by the IDisposable contract).

Lesson Learned

When decorators wrap IDisposable objects, always propagate Dispose through the entire chain. Use try/finally to guarantee the inner resource is released even if the outer disposal throws. Use leaveOpen parameters to make ownership explicit — only one object should control each resource's lifetime.

How to Spot This in Your Code

If you see intermittent IOException or ObjectDisposedException under load but not in local testing, check your decorator's Dispose() method. Is it using try/finally? Is ownership of the inner resource clear? Run a load test and watch file handle count in Process Monitor.

The Incident

ASP.NET Core payment service. The team builds a RetryDecorator that wraps IPaymentGateway.ChargeAsync() — if the payment gateway fails, the decorator retries up to 3 times with exponential backoff. Simple idea, and it works perfectly in testing (one request at a time).

In production, things go sideways fast. Under load (10+ concurrent payment requests), the entire application freezes. Requests hang for 30 seconds and then timeout. No exceptions, no errors in logs — just silence. The service appears completely healthy from the outside (health checks pass), but no work is getting done.

The root cause: the developer used .Result instead of await. This blocks the calling threadIn ASP.NET (pre-Core) and some UI frameworks, calling .Result or .Wait() on an async method blocks the current thread while the async method needs that same thread to complete its continuation. The thread waits for itself — classic deadlock. ASP.NET Core's thread pool mitigates this but still exhausts threads under load. while waiting for the async operation to complete. With enough concurrent requests, every thread in the thread pool is blocked waiting, and there are no free threads left to actually do the work. It's like everyone in a restaurant waiting for a waiter, but all the waiters are also waiting for something — nobody moves.

What Went Wrong

Thread Pool Under Load Thread 1: BLOCKED waiting on .Result Thread 2: BLOCKED waiting on .Result Thread 3: BLOCKED waiting on .Result Thread 4: BLOCKED waiting on .Result Thread N... ⏳ Incoming requests queued — no threads available to process them Requests wait 30s → timeout → 503 Service Unavailable ✅ With await: thread released back to pool while waiting ❌ With .Result: thread sits idle, holding its seat

Time to Diagnose

8 hours — thread dumps showed all threads blocked on .Result. The deadlock only appeared under load (10+ concurrent requests) and never in local debugging with a single request. This is the classic "works on my machine" async trap.

BuggyAsyncDecorator.cs
public class RetryDecorator : IPaymentGateway
{
    private readonly IPaymentGateway _inner;

    public async Task<PaymentResult> ChargeAsync(PaymentRequest request)
    {
        for (int attempt = 1; attempt <= 3; attempt++)
        {
            try
            {
                // ❌ .Result blocks the thread — deadlock under load!
                return _inner.ChargeAsync(request).Result;
            }
            catch (Exception) when (attempt < 3)
            {
                Thread.Sleep(1000 * attempt); // ❌ also blocking!
            }
        }
        throw new PaymentException("All retries failed");
    }
}

Walking through the buggy code: Two blocking calls hide in this code. First, .Result on line 12 — this tells the current thread "sit here and wait until the async operation finishes." The thread can't do anything else while waiting. Second, Thread.Sleep() on line 16 — this literally puts the thread to sleep for 1-3 seconds. During that time, the thread is holding a seat in the thread pool but doing zero work. Multiply this by 10+ concurrent requests, and every thread is either blocked on .Result or sleeping on Thread.Sleep. No threads left to process new requests — the entire service freezes.

FixedAsyncDecorator.cs
public class RetryDecorator : IPaymentGateway
{
    private readonly IPaymentGateway _inner;

    public async Task<PaymentResult> ChargeAsync(PaymentRequest request)
    {
        for (int attempt = 1; attempt <= 3; attempt++)
        {
            try
            {
                // ✅ await — non-blocking, no deadlock
                return await _inner.ChargeAsync(request);
            }
            catch (Exception) when (attempt < 3)
            {
                // ✅ Task.Delay — non-blocking wait
                await Task.Delay(1000 * attempt);
            }
        }
        throw new PaymentException("All retries failed");
    }
}

Why the fix works: await tells the thread "go do something else — I'll come back when the result is ready." The thread is released back to the thread pool while the async operation runs. Similarly, Task.Delay() sets a timer and releases the thread during the wait, unlike Thread.Sleep() which holds the thread hostage. With await, 100 concurrent requests can be served by just a handful of threads, because each thread handles multiple requests by switching between them while they wait for I/O.

Lesson Learned

If the interface method is async (Task/Task<T>), the decorator must be async all the way through. Never use .Result, .Wait(), or Thread.Sleep() inside an async decorator. Use await and Task.Delay() instead.

How to Spot This in Your Code

Search your codebase for .Result, .Wait(), and Thread.Sleep() inside async methods. If you find any, they're potential deadlock landmines. The fix is almost always: replace .Result with await, replace Thread.Sleep with await Task.Delay.

The Incident

Event bus with subscriber deduplication. The team builds an in-memory event bus that uses a HashSet<IEventHandler> to track active subscribers. The HashSet prevents duplicate registrations — if the same handler is registered twice, it's ignored.

Later, the team adds a logging decorator to all event handlers via DI. Now the same OrderHandler instance exists in two forms: the raw handler and the decorated version (LoggingDecorator(OrderHandler)). The HashSet sees these as two different objects because they have different GetHashCode() values (default object identity). Both get registered. Every event now fires the handler twice — once through the decorator, once directly.

The symptom: orders are sending double confirmation emails, inventory is decremented twice, and audit logs show duplicate entries. The app doesn't crash — it just does everything twice.

What Went Wrong

Think of it like a guest list at a party. "John Smith" is on the list. Then "John Smith wearing a hat" shows up. The bouncer checks the list and says "You're not on here — the John I have doesn't wear a hat." Both get in. A decorator is like putting a hat on someone — they're still the same person, but the default identity check (which looks at the hat, not the person) doesn't know that.

HashSet<IEventHandler> OrderHandler (hash: 847) LoggingDecorator (hash: 293) ❌ Both added — different hash codes! Same handler fires TWICE per event Direct registration Via DI (decorated) On each event: 📧 2× emails sent 📦 2× inventory deducted

Time to Diagnose

4 hours — event handlers appeared to fire twice. Adding breakpoints showed two different object references for what was logically the same handler. The confusion: everything worked correctly before adding the logging decorator, and the decorator itself had no bugs.

BuggyEquality.cs
// Decorator uses default Equals/GetHashCode (object identity)
public class LoggingHandlerDecorator<T> : IEventHandler<T>
{
    private readonly IEventHandler<T> _inner;
    private readonly ILogger _logger;

    public LoggingHandlerDecorator(IEventHandler<T> inner, ILogger logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public Task HandleAsync(T evt)
    {
        _logger.LogInformation("Handling {Event}", typeof(T).Name);
        return _inner.HandleAsync(evt);
    }
    // ❌ No Equals/GetHashCode override
    // ❌ new LoggingHandlerDecorator(handler) != handler in HashSet
}

// ❌ HashSet sees these as different:
var handler = new OrderHandler();
var decorated = new LoggingHandlerDecorator<OrderEvent>(handler, logger);
hashSet.Add(handler);     // added
hashSet.Add(decorated);   // ALSO added — duplicate!

Walking through the buggy code: By default, every C# object gets its Equals() and GetHashCode() from System.Object, which uses reference identity — two objects are only "equal" if they're literally the same memory address. The LoggingHandlerDecorator is a completely new object with a different address than the OrderHandler it wraps. So even though they represent the same logical handler, the HashSet sees two distinct entries. The decorator never overrides Equals or GetHashCode to say "I'm the same as my inner handler."

FixedEquality.cs
// ✅ Delegate Equals/GetHashCode to the inner component
public class LoggingHandlerDecorator<T> : IEventHandler<T>
{
    private readonly IEventHandler<T> _inner;
    private readonly ILogger _logger;

    public LoggingHandlerDecorator(IEventHandler<T> inner, ILogger logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public Task HandleAsync(T evt)
    {
        _logger.LogInformation("Handling {Event}", typeof(T).Name);
        return _inner.HandleAsync(evt);
    }

    // ✅ Identity is based on the INNER handler, not the wrapper
    public override bool Equals(object? obj) => obj switch
    {
        LoggingHandlerDecorator<T> other => _inner.Equals(other._inner),
        IEventHandler<T> other => _inner.Equals(other),
        _ => false
    };

    public override int GetHashCode() => _inner.GetHashCode();
}

// ✅ Now HashSet treats decorated and undecorated as same:
hashSet.Add(handler);     // added
hashSet.Add(decorated);   // NOT added — Equals returns true

Why the fix works: By overriding Equals() and GetHashCode(), we tell C#: "This decorator's identity is the same as whatever it wraps." When the HashSet checks GetHashCode(), both the raw handler and the decorator return the same hash code (the inner handler's). When it checks Equals(), the decorator compares its inner handler to the other object. Now the HashSet correctly recognizes them as duplicates and prevents the double registration.

Lesson Learned

If decorated objects participate in collections that use equality (HashSet, Dictionary, Distinct()), override Equals() and GetHashCode() to delegate to the inner component. A decorator is logically the same object — its identity should match.

How to Spot This in Your Code

If side effects (emails, database writes, notifications) happen more than once when they should happen once, and you recently added a decorator — check if the decorated objects are stored in any HashSet, Dictionary, or compared with .Equals(). The decorator might be seen as a different object than the original.

Section 13

Pitfalls & Anti-Patterns

Mistake: Writing a decorator that extends OrderService (concrete class) instead of implementing IOrderService (interface).

Why This Happens: You might think "I already have OrderService, I'll just extend it and add logging in the override." It feels natural — inheritance is the first tool most developers reach for. But this couples your decorator directly to the concrete class. If OrderService changes its constructor, adds a new field, or restructures its internals, your decorator breaks. Worse: you can't stack multiple decorators easily, because each one extends a different concrete class.

BAD — extends concrete class OrderService extends LoggingDecorator Tightly coupled — can't stack Constructor changes break decorator GOOD — implements interface IOrderService Logging Caching Retry Loosely coupled — stackable!
❌ Bad — decorating concrete class
// ❌ Extends concrete class — tightly coupled
public class LoggingOrderService : OrderService
{
    public LoggingOrderService(IDbContext db, ILogger logger)
        : base(db) { } // Must know OrderService's constructor!

    public override async Task<Order> CreateAsync(CreateOrderRequest req)
    {
        _logger.LogInformation("Creating order...");
        return await base.CreateAsync(req); // calls concrete method
    }
}
// ❌ Can't stack: LoggingOrderService + CachingOrderService?
// CachingOrderService also extends OrderService — can't extend both!
✅ Good — decorating interface
// ✅ Implements interface — loosely coupled
public class LoggingOrderDecorator : IOrderService
{
    private readonly IOrderService _inner; // could be anything!
    private readonly ILogger _logger;

    public LoggingOrderDecorator(IOrderService inner, ILogger logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public async Task<Order> CreateAsync(CreateOrderRequest req)
    {
        _logger.LogInformation("Creating order...");
        return await _inner.CreateAsync(req); // delegates to interface
    }
}
// ✅ Stackable: new LoggingDecorator(new CachingDecorator(new OrderService()))

The key difference: When you decorate the interface, your decorator doesn't care what's inside. It could wrap the real service, another decorator, or a mock for testing. That's the composability that makes Decorator powerful.

Mistake: Building a single SuperDecorator that adds logging AND caching AND retry AND validation AND metrics all in one class.

Why This Happens: You start with just logging. Then someone asks for caching, so you add it to the same class. Then retry logic. Then metrics. Before you know it, you have a 500-line "decorator" with 8 constructor parameters that does everything. It feels efficient — one class, one place — but you've just recreated the monolith problem that Decorator was supposed to solve.

BAD — God Decorator SuperDecorator Logging + Caching + Retry + Metrics + Validation 500 lines, 8 params All or nothing — can't opt out GOOD — One concern each Logging Caching Retry Metrics Validation ~50 lines each, independently testable Add/remove one line in DI
❌ Bad — God Decorator
// ❌ One decorator doing EVERYTHING
public class SuperOrderDecorator : IOrderService
{
    private readonly IOrderService _inner;
    private readonly ILogger _logger;
    private readonly IMemoryCache _cache;
    private readonly IMetrics _metrics;
    private readonly RetryPolicy _retryPolicy;
    // ...8 constructor params, 500 lines

    public async Task<Order> CreateAsync(CreateOrderRequest req)
    {
        _logger.LogInformation("Creating...");    // logging
        _metrics.IncrementCounter("orders");      // metrics
        if (_cache.TryGet(req.Key, out var cached)) return cached; // caching
        return await _retryPolicy.ExecuteAsync(   // retry
            () => _inner.CreateAsync(req));
    }
}
// ❌ Want caching without retry? Can't. It's all or nothing.
✅ Good — One decorator per concern
// ✅ Each decorator does ONE thing — compose in DI
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, CachingDecorator>();   // opt-in
services.Decorate<IOrderService, RetryDecorator>();     // opt-in
services.Decorate<IOrderService, MetricsDecorator>();   // opt-in
services.Decorate<IOrderService, LoggingDecorator>();   // opt-in

// ✅ Want caching without retry? Just remove one line.
// ✅ Each decorator: ~50 lines, 1 constructor param, independently testable.

Rule of thumb: if your decorator has more than 2-3 constructor parameters (beyond the inner service), it's probably doing too much. Split it.

Mistake: Every decorator manually implements all 8 interface methods, copy-pasting the same _inner.MethodX() forwarding in each one. You now have 5 decorators with 40 identical forwarding methods.

Why Bad: When the interface gains a new method, you must update every single decorator. Miss one? Silent bug (see Bug Study #1). It's also a massive code review burden — reviewers have to verify every forwarding call is correct.

Fix: Create an abstract OrderServiceDecoratorBase that implements the interface and forwards everything to _inner by default. Each concrete decorator extends this base and overrides only the methods it cares about. New interface methods? Add them to the base once.

BAD — no base class LoggingDecorator 8 forwarding methods CachingDecorator 8 forwarding methods RetryDecorator 8 forwarding methods = 24 copy-paste methods! GOOD — base forwards all DecoratorBase (8 methods) Logging (1 override) Caching (1 override) New method? Update base once
BaseDecorator.cs
// ✅ Base decorator — forwards everything by default
public abstract class OrderServiceDecoratorBase : IOrderService
{
    protected readonly IOrderService Inner;
    protected OrderServiceDecoratorBase(IOrderService inner) => Inner = inner;

    public virtual Task<Order> CreateAsync(CreateOrderRequest req)
        => Inner.CreateAsync(req);
    public virtual Task CancelAsync(Guid orderId)
        => Inner.CancelAsync(orderId);
    public virtual Task<Order?> GetByIdAsync(Guid orderId)
        => Inner.GetByIdAsync(orderId);
}

// ✅ Concrete decorator — override only what you need
public class LoggingOrderDecorator : OrderServiceDecoratorBase
{
    private readonly ILogger _logger;
    public LoggingOrderDecorator(IOrderService inner, ILogger logger)
        : base(inner) => _logger = logger;

    public override async Task<Order> CreateAsync(CreateOrderRequest req)
    {
        _logger.LogInformation("Creating order for {Customer}", req.CustomerId);
        return await base.CreateAsync(req);
    }
    // CancelAsync and GetByIdAsync auto-forward — zero boilerplate
}

Mistake: Manually constructing decorator chains with new instead of letting the DI container handle it.

Why This Happens: You might think "I'll just manually wire the decorators in Program.cs to keep things simple." So you write something like new LoggingDecorator(new CachingDecorator(new OrderService(db)), logger). This works for 2-3 dependencies. But as the chain grows, you have to manually pass every dependency to every constructor — miss one and you get a NullReferenceException at runtime. It's also fragile: if any constructor signature changes, you must update the manual wiring.

BAD — manual new() Service Cache Log new Log(new Cache(new Svc(db)), logger) You wire every dependency by hand Miss one = NullReferenceException GOOD — DI container DI Container Svc Cache Log Auto-resolves all dependencies
❌ Bad — manual wiring
// ❌ Manual construction — fragile, verbose, error-prone
var db = new SqlOrderRepository(connectionString);
var service = new OrderService(db);
var cached = new CachingDecorator(service, new MemoryCache(options));
var logged = new LoggingDecorator(cached, loggerFactory.CreateLogger<...>());
// ❌ Every new decorator means updating this chain manually
✅ Good — DI handles everything
// ✅ DI resolves all constructor params automatically
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, CachingDecorator>();
services.Decorate<IOrderService, LoggingDecorator>();
// ✅ Add/remove decorators = add/remove one line. Done.

Fix: Use ScrutorScrutor is a .NET library that extends Microsoft's DI container with assembly scanning and decoration support. services.Decorate<IService, Decorator>() automatically wraps the existing registration. The decorator's constructor receives the "inner" service plus any other dependencies from the container. Install: dotnet add package Scrutor's services.Decorate<TService, TDecorator>(). It automatically resolves all constructor parameters, including the inner service. No manual wiring, no forgotten dependencies.

Mistake: Using the Decorator pattern when a simple method call or inheritance would do the job perfectly.

Why This Happens: After learning Decorator, you might start seeing every problem as a nail. "I need to add logging? Decorator! Validation? Decorator! Error handling? Decorator!" But if you only have one implementation and the behavior is always needed (not optional, not combinable), creating a full decorator chain is like building a highway to cross the street.

BAD — over-engineered Interface Concrete LoggingDecorator 3 classes + DI wiring ...for behavior that's ALWAYS on and never removed GOOD — just put it in the class UserRepository _logger.Log(...) _db.Users.FindAsync(...) 1 class, simple, done
❌ Over-engineered — decorator for permanent behavior
// ❌ 3 classes + DI wiring for something that's ALWAYS on
public interface IUserRepository { Task<User> GetByIdAsync(int id); }
public class UserRepository : IUserRepository { ... }
public class LoggingUserRepository : IUserRepository { ... } // always needed
// Program.cs:
services.AddScoped<IUserRepository, UserRepository>();
services.Decorate<IUserRepository, LoggingUserRepository>(); // never removed
✅ Simple — just add logging directly
// ✅ If logging is ALWAYS needed and there's only one implementation:
public class UserRepository : IUserRepository
{
    private readonly ILogger<UserRepository> _logger;

    public async Task<User> GetByIdAsync(int id)
    {
        _logger.LogInformation("Getting user {Id}", id);
        return await _db.Users.FindAsync(id);
    }
}

When to use Decorator vs direct code: Ask yourself two questions: (1) Will I ever want this behavior without the other? (2) Will there be multiple combinations? If both answers are no, just put the code in the class. Decorator is a tool for composability — if nothing is being composed, use simpler tools.

Mistake: Misconfigured DI where decorators accidentally create a circular dependency.

Why This Happens: You might have a LoggingDecorator that wraps IOrderService, but internally it also depends on INotificationService. Meanwhile, the INotificationService implementation depends on IOrderService. The DI container tries to resolve A → B → A → B... and throws a StackOverflowException or a circular dependency error. The stack trace is just pages of the same two constructors calling each other — very confusing to debug.

BAD — circular dependency LoggingDecorator NotificationSvc A needs B needs A = StackOverflow GOOD — linear chain Real Cache Log One direction only — no cycles Only cross-cutting deps (ILogger, IMetrics)
❌ Bad — circular dependency
// ❌ LoggingDecorator needs INotificationService...
public class LoggingOrderDecorator : IOrderService
{
    public LoggingOrderDecorator(IOrderService inner,
        INotificationService notifier) { } // ❌ circular!
}

// ❌ ...but NotificationService needs IOrderService
public class NotificationService : INotificationService
{
    public NotificationService(IOrderService orders) { } // ❌ back to start!
}
// DI tries: IOrderService → LoggingDecorator → INotificationService
//   → NotificationService → IOrderService → LoggingDecorator → 💥

Fix: Keep decoration linear: Real → Decorator1 → Decorator2. Decorators should only depend on the interface they're decorating plus cross-cutting concerns (ILogger, IMetrics). If a decorator needs another service, consider using Lazy<T> to break the circular dependency, or restructure so the dependency flows one way.

Mistake: A decorator changes the behavioral contract that callers expect from the interface.

Why This Happens: You add a CachingDecorator that returns data from 30 minutes ago, but the interface contract implies "current data." Or a RetryDecorator catches exceptions and returns a fallback value, but the caller expects to handle those exceptions. The decorator looks like it implements the same interface, but it behaves differently in ways callers don't expect. Bugs show up far from the decorator code, because the caller's assumptions are silently violated.

BAD — contract broken Caller RetryDecorator Error? Returns null! Caller expects: Order or Exception Gets: null (surprise!) GOOD — contract honored Caller RetryDecorator Error? Re-throws it! Caller expects: Order or Exception Gets: Order or Exception (correct!)
❌ Bad — decorator silently swallows errors
// ❌ RetryDecorator swallows the final exception
public async Task<Order> CreateAsync(CreateOrderRequest req)
{
    for (int i = 0; i < 3; i++)
    {
        try { return await _inner.CreateAsync(req); }
        catch { /* swallow and retry */ }
    }
    return null!; // ❌ Returns null instead of throwing!
    // Caller expects an Order or an exception — gets neither
}
✅ Good — decorator preserves the contract
// ✅ Retry decorator re-throws the final exception
public async Task<Order> CreateAsync(CreateOrderRequest req)
{
    Exception? lastException = null;
    for (int i = 0; i < 3; i++)
    {
        try { return await _inner.CreateAsync(req); }
        catch (Exception ex) { lastException = ex; }
    }
    throw lastException!; // ✅ Caller still sees the failure
}

Fix: A decorator must honor the interface contract. If the interface says "returns Order or throws," the decorator must do the same. Caching is fine if you document the staleness window. Retry is fine if you re-throw the final exception. The key test: does the caller need to know whether a decorator is present? If yes, the contract is broken.

Mistake: Only testing the full decorator chain end-to-end, never testing decorators individually.

Why This Happens: You might think "The chain works as a whole in production, so I should test the whole chain." But when a test fails, you have no idea which decorator is broken. Is it the CachingDecorator? The LoggingDecorator? The real service? You spend hours adding breakpoints and peeling layers to isolate the problem.

BAD — test full chain only Log Cache Retry Real Svc Test fails... Which layer broke? No idea! Hours of debugging to isolate GOOD — test each in isolation CacheDecorator + Mock inner LogDecorator + Mock inner Test fails? You know exactly where Mock inner = predictable inputs Test verifies only that decorator's logic
✅ Good — test each decorator in isolation
// ✅ Mock the inner service — test ONLY the decorator's behavior
[Fact]
public async Task CachingDecorator_SecondCall_ReturnsCached()
{
    var mock = new Mock<IOrderService>();
    mock.Setup(s => s.GetByIdAsync(It.IsAny<Guid>()))
        .ReturnsAsync(new Order { Id = Guid.NewGuid() });

    var decorator = new CachingDecorator(mock.Object, new MemoryCache(...));

    var first = await decorator.GetByIdAsync(orderId);
    var second = await decorator.GetByIdAsync(orderId); // should hit cache

    mock.Verify(s => s.GetByIdAsync(orderId), Times.Once); // ✅ inner called once
    Assert.Same(first, second); // ✅ same cached object
}

Fix: Test each decorator with a mock inner service. The mock returns predictable data; the test verifies only the decorator's specific behavior. Then add one integration test for the full chain as a sanity check.

Section 14

Testing Strategies

Decorators are one of the most testable patterns out there. Because each decorator takes an interface in its constructor, you can mock the inner service and test each layer in complete isolation. Here are four approaches from unit tests to DI verification.

Before testing decorators, make sure the base component works on its own. This is your foundation — if the real service has a bug, no decorator can fix it. Test the concrete service with real (or in-memory) dependencies.

BaseComponentTests.cs
public class OrderServiceTests
{
    [Fact]
    public async Task CreateOrder_ValidRequest_ReturnsOrderWithId()
    {
        // Arrange — test the REAL service, no decorators
        var db = new InMemoryOrderRepository();
        var service = new OrderService(db);

        // Act
        var order = await service.CreateAsync(new CreateOrderRequest
        {
            CustomerId = Guid.NewGuid(),
            Items = new[] { new OrderItem("Widget", 2, 9.99m) }
        });

        // Assert — base behavior works
        Assert.NotEqual(Guid.Empty, order.Id);
        Assert.Single(order.Items);
        Assert.Equal(19.98m, order.Total);
    }

    [Fact]
    public async Task CreateOrder_EmptyItems_ThrowsValidationException()
    {
        var db = new InMemoryOrderRepository();
        var service = new OrderService(db);

        await Assert.ThrowsAsync<ValidationException>(
            () => service.CreateAsync(new CreateOrderRequest
            {
                CustomerId = Guid.NewGuid(),
                Items = Array.Empty<OrderItem>()
            }));
    }
}

This is the most important testing strategy for decorators. Mock the inner service and verify only that the decorator adds its specific behavior. The inner mock returns predictable data — you're testing the decorator's logic, not the service underneath.

IsolatedDecoratorTests.cs
public class LoggingDecoratorTests
{
    [Fact]
    public async Task CreateOrder_LogsEntryAndExit()
    {
        // Arrange — mock the inner service
        var mockInner = new Mock<IOrderService>();
        mockInner.Setup(s => s.CreateAsync(It.IsAny<CreateOrderRequest>()))
            .ReturnsAsync(new Order { Id = Guid.NewGuid() });

        var fakeLogger = new FakeLogger<LoggingOrderDecorator>();
        var decorator = new LoggingOrderDecorator(mockInner.Object, fakeLogger);

        // Act
        await decorator.CreateAsync(new CreateOrderRequest { CustomerId = Guid.NewGuid() });

        // Assert — decorator logged, inner was called
        Assert.Contains(fakeLogger.Messages, m => m.Contains("Creating order"));
        Assert.Contains(fakeLogger.Messages, m => m.Contains("Order created"));
        mockInner.Verify(s => s.CreateAsync(It.IsAny<CreateOrderRequest>()), Times.Once);
    }

    [Fact]
    public async Task CreateOrder_InnerThrows_LogsErrorAndRethrows()
    {
        var mockInner = new Mock<IOrderService>();
        mockInner.Setup(s => s.CreateAsync(It.IsAny<CreateOrderRequest>()))
            .ThrowsAsync(new InvalidOperationException("DB down"));

        var fakeLogger = new FakeLogger<LoggingOrderDecorator>();
        var decorator = new LoggingOrderDecorator(mockInner.Object, fakeLogger);

        // Assert — decorator logs AND rethrows (doesn't swallow)
        await Assert.ThrowsAsync<InvalidOperationException>(
            () => decorator.CreateAsync(new CreateOrderRequest()));
        Assert.Contains(fakeLogger.Messages, m => m.Contains("Error"));
    }
}

public class CachingDecoratorTests
{
    [Fact]
    public async Task GetById_SecondCall_ReturnsCachedResult()
    {
        var mockInner = new Mock<IOrderService>();
        var order = new Order { Id = Guid.NewGuid(), Total = 42m };
        mockInner.Setup(s => s.GetByIdAsync(order.Id)).ReturnsAsync(order);

        var cache = new MemoryCache(new MemoryCacheOptions());
        var decorator = new CachingOrderDecorator(mockInner.Object, cache);

        // Act — call twice
        var result1 = await decorator.GetByIdAsync(order.Id);
        var result2 = await decorator.GetByIdAsync(order.Id);

        // Assert — inner called only ONCE (second was cached)
        Assert.Equal(order.Id, result1!.Id);
        Assert.Same(result1, result2);
        mockInner.Verify(s => s.GetByIdAsync(order.Id), Times.Once);
    }
}

Once each decorator passes isolated tests, verify the full chain works together. This is an integration test — all decorators stacked on a real (or in-memory) service. Check that behaviors compose correctly and don't interfere.

IntegrationTests.cs
public class FullChainIntegrationTests
{
    [Fact]
    public async Task FullChain_CreateOrder_LogsCachesAndRetries()
    {
        // Arrange — build the full decorator chain manually
        var db = new InMemoryOrderRepository();
        IOrderService service = new OrderService(db);              // real service
        service = new CachingOrderDecorator(service, new MemoryCache(
            new MemoryCacheOptions()));                            // + caching
        service = new RetryOrderDecorator(service, maxRetries: 3); // + retry
        var logger = new FakeLogger<LoggingOrderDecorator>();
        service = new LoggingOrderDecorator(service, logger);      // + logging (outermost)

        // Act
        var order = await service.CreateAsync(new CreateOrderRequest
        {
            CustomerId = Guid.NewGuid(),
            Items = new[] { new OrderItem("Gadget", 1, 29.99m) }
        });

        // Assert — all layers participated
        Assert.NotEqual(Guid.Empty, order.Id);           // service worked
        Assert.Contains(logger.Messages, m => m.Contains("Creating")); // logged

        // Second read should come from cache
        var cached = await service.GetByIdAsync(order.Id);
        Assert.Same(order, cached);                       // cached hit
    }
}

The final safety net: verify your DI container actually wires the decorators in the right order. This catches misconfigurations before they hit production.

DIRegistrationTests.cs
public class DIRegistrationTests
{
    [Fact]
    public void OrderService_IsDecoratedInCorrectOrder()
    {
        // Arrange — build the real DI container
        var services = new ServiceCollection();
        services.AddOrderServices(); // your extension method

        var provider = services.BuildServiceProvider();
        var service = provider.GetRequiredService<IOrderService>();

        // Assert — outermost is LoggingDecorator
        Assert.IsType<LoggingOrderDecorator>(service);

        // Peel one layer — next is RetryDecorator
        var logging = (LoggingOrderDecorator)service;
        Assert.IsType<RetryOrderDecorator>(logging.Inner);

        // Peel another — CachingDecorator
        var retry = (RetryOrderDecorator)logging.Inner;
        Assert.IsType<CachingOrderDecorator>(retry.Inner);

        // Innermost — the real service
        var caching = (CachingOrderDecorator)retry.Inner;
        Assert.IsType<OrderService>(caching.Inner);
    }
}
Last Resort: Exposing Inner as a property just for testing is a code smell. Consider instead testing behavior (e.g., "logs appear when I create an order") rather than structure. Use the structural test only as a safety net in complex DI setups.
Section 15

Performance Considerations

For most applications, decorator overhead is negligible — we're talking nanoseconds per layer. But if you're decorating hot paths called millions of times, the cost adds up. Here's what actually matters and what doesn't.

Allocation Overhead

Every decorator is an extra object on the heapThe region of memory where objects live in .NET. Unlike the stack (which stores local variables and is cleaned up automatically when a method returns), heap objects persist until the garbage collector reclaims them. Every time you write "new SomeClass()", that object lands on the heap.. In a DI container, decorators are typically created once (singleton/scoped) so allocation happens at startup, not per-request. But if you're creating decorator chains per-request or per-operation, the allocations add up.

Decorator LayersObject AllocationsCall Overhead (per method)GC Impact
1 decorator+1 object (~32 bytes)~2-5 ns (1 virtual call)Negligible
3 decorators+3 objects (~96 bytes)~6-15 ns (3 virtual calls)Negligible
5 decorators+5 objects (~160 bytes)~10-25 ns (5 virtual calls)Low
10+ decorators+10 objects (~320 bytes)~20-50 ns (10 virtual calls)Moderate if per-request
Mitigation: Register decorators as singletons when stateless (logging, metrics). For stateful decorators (caching with per-user data), use scoped lifetime. Avoid transient decorator registrations unless you specifically need a fresh instance every time.

Each decorator layer means one more virtual method callWhen you call a method through an interface (IOrderService.CreateAsync), the runtime must look up the actual implementation at runtime via a vtable pointer — this is virtual dispatch. Each lookup takes 2-5 nanoseconds. Modern CPUs predict these well, but deep chains can blow the branch predictor's cache.. The CPU must look up the actual method implementation through a pointer table (vtable). For 1-5 layers, this is completely invisible — modern CPUs predict virtual calls extremely well.

BenchmarkResults.cs
// BenchmarkDotNet results — .NET 8, x64, Intel Core i7
// Calling IOrderService.GetByIdAsync() with N decorator layers

// | Layers | Mean      | Overhead vs Direct |
// |--------|-----------|-------------------|
// | 0      | 145 ns    | baseline          |
// | 1      | 148 ns    | +3 ns (2%)        |
// | 3      | 156 ns    | +11 ns (7%)       |
// | 5      | 164 ns    | +19 ns (13%)      |
// | 10     | 185 ns    | +40 ns (27%)      |

// Verdict: even 10 layers add only 40ns.
// If your method does ANY I/O (DB, HTTP, file), the decorator
// overhead is less than 0.001% of total method time.

// ⚠️ Where it DOES matter: tight computational loops
// calling a decorated method 10 million times/second.
// In that case, consider inlining the decoration logic.

If decorators are created and discarded frequently (transient lifetime in DI, or new chain per request), the short-lived wrapper objects land in Gen 0The youngest generation in .NET's garbage collector. Newly allocated objects start in Gen 0. If they survive a GC cycle, they're promoted to Gen 1, then Gen 2. Gen 0 collections are fast but frequent — if you're dumping thousands of short-lived decorator objects here per second, the GC has to work harder to clean them up. and create garbage collection pressure. This matters in high-throughput scenarios (>10K requests/second).

GCMitigation.cs
// ❌ BAD: Creating decorator chain per request (transient)
services.AddTransient<IOrderService>(sp =>
{
    // 3 NEW objects created on EVERY request — 30K objects/sec at 10K RPS
    IOrderService svc = new OrderService(sp.GetRequiredService<IOrderRepository>());
    svc = new CachingOrderDecorator(svc, sp.GetRequiredService<IMemoryCache>());
    svc = new LoggingOrderDecorator(svc, sp.GetRequiredService<ILogger>());
    return svc;
});

// ✅ GOOD: Singleton decorator chain (stateless decorators)
services.AddSingleton<IOrderService, OrderService>();
services.Decorate<IOrderService, CachingOrderDecorator>();
services.Decorate<IOrderService, LoggingOrderDecorator>();
// Chain created ONCE at startup — zero per-request GC pressure

// ✅ ALSO GOOD: Scoped when decorators need per-request state
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, TenantContextDecorator>();
// Created once per request scope — much better than transient
Section 16

How to Explain in an Interview

Your Script (90 Seconds)

Opening: "Decorator lets you add new behavior to an object without changing its code. Think of it like gift wrapping — you start with a box, wrap it in tissue paper, then wrapping paper, then a bow. Each layer adds something, but the box inside is untouched."

Core: "The key mechanism is composition over inheritance. Instead of creating subclasses for every combination of features, you wrap objects. A LoggingDecorator wraps any IOrderService — it implements the same interface, delegates to the inner service, and adds logging around each call. Stack as many decorators as you need."

Example: ".NET's Stream class is the textbook example. FileStream reads from disk. Wrap it in BufferedStream to add buffering. Wrap that in GZipStream to add compression. Each layer does one thing, and they compose cleanly. In ASP.NET, middleware is essentially Decorator — each middleware wraps the next in the pipeline."

When: "I reach for Decorator when I have cross-cutting concerns — logging, caching, retry, circuit breaking — that I want to apply independently and optionally. In DI, I use Scrutor's services.Decorate<TService, TDecorator>() to wire them up cleanly."

Close: "The key insight is that each decorator does exactly one thing and is independently testable. I mock the inner service, test the decorator's added behavior, and I'm done. It's one of the most practical patterns I use daily."

Section 17

Interview Q&As

29 questions that interviewers actually ask about Decorator — from the basics all the way to production-level scenarios. Each has a "Think First" prompt (try answering before peeking!) and a "Great Answer Bonus" to help you stand out from other candidates.

Easy Foundations (Q1—Q5)

Think First Imagine you have 4 optional features (logging, caching, retry, metrics) for one service. How many subclass combinations would you need with inheritance?

Decorator solves the "feature combination explosion" problem. Here's the scenario: you have a service that sends notifications, and you want to optionally add logging, caching, retry, and metrics to it. With inheritance, you'd need a separate subclass for every possible combination — LoggingService, CachingService, LoggingCachingService, LoggingRetryService, LoggingCachingRetryMetricsService... that's 24 = 16 classes for just 4 features. Add a 5th feature? Now it's 32 classes.

Decorator avoids this entirely. You write 4 small wrapper classes — one per feature. Each one implements the same interface as the thing it wraps, adds one behavior, and delegates everything else to the inner object. At runtime, you stack whichever ones you need: new Logging(new Retry(new RealService())). Want caching too? Just wrap another layer. Don't need retry? Leave it out. The client code doesn't know (or care) how many layers are around the real service — it just sees the interface.

❌ Inheritance: 16 classes Service LoggingService CachingService RetryService LogCache LogRetry CacheRetry LogCacheRetry LogMetrics ... + 8 more 4 features = 16 subclasses 5 features = 32 subclasses N features = 2ᴺ subclasses 💥 ✅ Decorator: 4 classes LoggingDecorator CachingDecorator RetryDecorator MetricsDecorator N features = N classes ✅ Stack any combo at runtime
Great Answer Bonus "Decorator embodies the Open/Closed Principle perfectly — I can add new behaviors without modifying any existing class. In .NET, the Stream class hierarchy is the canonical example: FileStream, BufferedStream, GZipStream, CryptoStream all compose through wrapping."
Think First Can you add logging to an object at runtime using inheritance? What about removing it?

Think of it this way: inheritance is like baking a cake with the ingredients already mixed in. Once it's baked, you can't remove the chocolate. Decorator is like adding toppings — you can add frosting, sprinkles, and a cherry, and you can always scrape off a topping you don't want.

Technically, inheritance fixes behavior at compile time. You pick a subclass (LoggingOrderService) and that's what you get forever. If you want logging + caching, you need a new subclass LoggingCachingOrderService. Decorator adds behavior at runtime by wrapping objects. You stack wrappers dynamically: new Logging(new Caching(new RealService())). Tomorrow you can swap out Caching for Redis without rewriting anything.

Three practical differences that matter in interviews:

  1. Combinability: Inheritance needs a new class for every combination (2N explosion). Decorator needs N classes total — compose any subset.
  2. Runtime flexibility: With Decorator, you can add/remove behavior based on configuration, feature flags, or tenant settings. Inheritance is static.
  3. C# limitation: C# doesn't support multiple inheritance. You literally can't create LoggingAndCachingService by inheriting from both LoggingService and CachingService. Decorator sidesteps this entirely through composition.
Inheritance vs Decorator
// INHERITANCE: behavior locked at compile time
var service = new LoggingCachingOrderService(db); // can't change at runtime
// Want logging without caching? Need ANOTHER class

// DECORATOR: behavior composed at runtime
IOrderService service = new OrderService(db);
service = new CachingDecorator(service);  // add caching
service = new LoggingDecorator(service);  // add logging
// Want logging without caching? Just remove one line
Great Answer Bonus "The Decorator relationship is 'has-a with the same interface' — the wrapper IS-A IOrderService but HAS-A IOrderService inside. This dual identity is what makes it transparent to the client. The client calls the same interface regardless of whether there are 0 or 10 decorators in between."
Think First Think of something you use daily where layers are added on top of a base item without changing the item itself.

A coffee shop is the classic analogy. Start with a plain espresso (the base component). Add milk → that's a latte (first decorator). Add whipped cream → another decorator. Add caramel drizzle → another decorator. Each addition wraps the previous drink, adds its own cost and flavor, but the espresso at the core is unchanged. You can combine any toppings you want, skip any you don't, and the barista doesn't need a separate recipe for every possible combination.

Great Answer Bonus "In software terms: the espresso implements ICoffee with a Cost() method. Each topping also implements ICoffee, holds a reference to the inner ICoffee, and adds its cost to the inner cost. Same interface all the way through."
Think First How many roles are in the Decorator pattern? What does each one do?

Four roles:

  1. Component (interface) — the contract both the real object and decorators implement. Example: IOrderService
  2. Concrete Component — the real object with actual business logic. Example: OrderService
  3. Base Decorator (abstract) — implements the interface and forwards all calls to the inner component. Optional but highly recommended to avoid forwarding boilerplate
  4. Concrete Decorators — extend the base decorator, override specific methods to add behavior. Example: LoggingOrderDecorator, CachingOrderDecorator
Great Answer Bonus "The base decorator is technically optional in the GoF definition, but in practice it's essential. Without it, every concrete decorator must manually forward every interface method — a maintenance nightmare when the interface changes."
Think First What makes a Decorator more than just a class that calls another class?

The key difference is that a Decorator implements the same interface as the object it wraps. This means decorators are transparent to the client — the client code works with IOrderService and doesn't know whether it's talking to the real service or 5 decorators deep. A plain wrapper typically has its own distinct interface, breaking this transparency. Because decorators share the interface, they can stack: Decorator A wraps Decorator B wraps Decorator C wraps the real object.

Great Answer Bonus "This interface-sharing property is what makes Decorator work seamlessly with DI containers. Scrutor's Decorate<TService, TDecorator>() works precisely because the decorator implements the same TService interface."

Medium Applied Knowledge (Q6—Q12)

Think First When you write new GZipStream(new BufferedStream(new FileStream(...))), which object is the "real" component and which are decorators?

Stream is the abstract base (the Component). FileStream, MemoryStream, NetworkStream are concrete components — they read/write actual data. BufferedStream, GZipStream, CryptoStream, BrotliStream are decorators — they take a Stream in their constructor and add a feature (buffering, compression, encryption) while still being a Stream themselves. You can stack any combination: compress then encrypt, buffer then compress, etc.

CryptoStream (encrypts bytes) GZipStream (compresses bytes) BufferedStream (batches I/O) FileStream ← the only one touching disk
Great Answer Bonus "The Stream pattern also shows a Decorator gotcha: the leaveOpen constructor parameter. By default, disposing the outer stream disposes the inner one too. Set leaveOpen: true when you need the inner stream to survive the outer's disposal."
Think First The built-in Microsoft DI container doesn't have a Decorate method. How would you wire a decorator without Scrutor?

Two approaches:

  1. Manual factory (built-in DI): Register a factory lambda that resolves the inner service and wraps it.
  2. Scrutor (recommended): Clean, readable, automatically handles all dependencies.
DI Registration
// Option 1: Manual factory (messy with multiple layers)
services.AddScoped<IOrderService>(sp =>
    new LoggingDecorator(
        new CachingDecorator(
            new OrderService(sp.GetRequiredService<IRepository>()),
            sp.GetRequiredService<IMemoryCache>()),
        sp.GetRequiredService<ILogger<LoggingDecorator>>()));

// Option 2: Scrutor (clean and composable)
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, CachingDecorator>();  // inner
services.Decorate<IOrderService, LoggingDecorator>();  // outer (last = outermost)
Great Answer Bonus "Scrutor's Decorate works by replacing the existing registration with a new one that takes the previous implementation as a constructor parameter. It's not magic — it's just automating the factory lambda approach."
Think First If a behavior is always needed and never optional, does Decorator add value?

Decorator is great for adding optional, combinable behaviors — but it's not always the right tool. Think of it like gift wrapping: wrapping a present in nice paper makes sense. But wrapping a single sticky note in three layers of paper, tape, and ribbon? Overkill. Here are the situations where Decorator causes more pain than it solves:

Avoid Decorator when:

  • There's only one behavior that's always applied — if logging is always on and never optional, just put it in the class. Decorator adds indirection for zero benefit when there's no "mix and match" happening.
  • You need access to private members — decorators only see the public interface. If the behavior you're adding needs to read internal state, Decorator can't help. Use inheritance or modify the class directly.
  • The interface is huge (20+ methods) — even with a base decorator, forwarding 20 methods is noisy. If your interface is that big, it probably violates Interface Segregation Principle (ISP). Split it first, then consider Decorator.
  • Performance-critical hot paths — each layer adds a virtual method dispatch (~2-5ns). For a database call, that's irrelevant. For a math function called 100 million times per second in a game engine, those nanoseconds add up.
  • Decoration order is fragile — if swapping two decorators causes bugs and the "correct" order isn't obvious, consider a Pipeline pattern (like MediatR behaviors) where order is explicit and documented.

The smell test: if you always apply the same decorators in the same order to every implementation, you probably don't need Decorator. Its power is in optional, combinable behaviors — if nothing is optional, you're adding complexity for no reason.

Great Answer Bonus "The smell test: if you always apply the same decorators in the same order to every implementation, you might not need Decorator at all. Decorator's power is in optional, combinable behaviors."
Think First In ASP.NET middleware, each middleware calls next() to invoke the next one. How does this relate to a decorator calling _inner.Method()?

ASP.NET Core middleware is essentially Decorator applied to HTTP request processing. Each middleware wraps the next one via the RequestDelegate next parameter. It can run code before calling next(context) (decorating the request) and after (decorating the response). The "base component" is the endpoint handler at the center. Just like Decorator, middleware is composable, order matters, and each layer does one thing (auth, logging, compression, CORS).

Side by side comparison
// MIDDLEWARE: wraps HTTP pipeline via RequestDelegate
public class LoggingMiddleware
{
    private readonly RequestDelegate _next; // "inner" component
    public async Task Invoke(HttpContext ctx)
    {
        Log("Before");
        await _next(ctx);  // forward to next layer
        Log("After");
    }
}

// DECORATOR: wraps specific interface via constructor injection
public class LoggingDecorator : IOrderService
{
    private readonly IOrderService _inner; // "inner" component
    public async Task<Order> CreateAsync(CreateOrderRequest req)
    {
        Log("Before");
        var result = await _inner.CreateAsync(req); // forward to inner
        Log("After");
        return result;
    }
}
// Same concept. Different abstraction level.
Great Answer Bonus "The difference is that middleware uses a delegate chain (functional style) while classic Decorator uses object wrapping (OOP style). The concept is identical — the implementation style differs. Middleware can also short-circuit (return without calling next), which is more like Chain of Responsibility."
Think First Both wrap an object behind the same interface. What's different about their intent?

This is one of the most common interview questions because the two patterns look almost identical in code. Both implement the same interface. Both hold a reference to the wrapped object. Both delegate calls to it. If you showed someone the code without class names, they might not be able to tell them apart.

The difference is intent — what you're trying to achieve:

  • Decorator: "I want to add behavior." Logging, caching, retry, metrics — the real service still runs, but extra behavior is layered around it. Decorators are designed to stack (multiple layers).
  • Proxy: "I want to control access." Lazy loading (create the object only when needed), security checks (verify permissions before calling), remote proxy (the object lives on another server). Proxies typically stand alone (one proxy, not stacked).
Decorator Intent: ADD behavior ✅ Stacks (Logging → Retry → Cache) ✅ Real service always runs ✅ Multiple decorators common Examples: LoggingDecorator, CachingDecorator Proxy Intent: CONTROL access ✅ Stands alone (one proxy) ✅ May prevent service from running ✅ Controls when/if object is created Examples: LazyProxy, SecurityProxy

In practice, some wrappers blur the line. A caching wrapper could be called either — it "adds" caching behavior (Decorator) but also "controls access" by deciding whether to call the real service (Proxy). The naming you choose tells other developers your intent.

Great Answer Bonus "In .NET, a clean example: HttpClientHandler is the real component. DelegatingHandler is the base decorator. LoggingHandler, RetryHandler are decorators. A LazyHttpClient that creates the HttpClient only on first use would be a Proxy. The code structure is identical — the intent and naming differ."
Think First If a CachingDecorator is registered as a singleton and multiple threads hit it simultaneously, what could go wrong?

The answer depends on whether your decorator has state (data it remembers between calls) or is stateless (just passes things through with some logic).

Stateless decorators (like a pure logging decorator) are automatically thread-safe — they don't store anything, so there's nothing for threads to fight over. Multiple threads can call them simultaneously with zero risk.

Stateful decorators (like a caching decorator that remembers previous results) need protection. If two threads try to update the same cache simultaneously, you get race conditions — corrupted data, duplicate computations, or crashes.

ThreadSafeCaching.cs
// ❌ NOT thread-safe — Dictionary is not designed for concurrent access
public class CachingDecorator : IOrderService
{
    private readonly Dictionary<int, Order> _cache = new();  // 💥 race condition
    private readonly IOrderService _inner;

    public Order GetOrder(int id)
    {
        if (!_cache.ContainsKey(id))          // Thread A checks: not cached
            _cache[id] = _inner.GetOrder(id); // Thread B also checks: not cached → duplicate call!
        return _cache[id];
    }
}

// ✅ Thread-safe — ConcurrentDictionary handles locking internally
public class CachingDecorator : IOrderService
{
    private readonly ConcurrentDictionary<int, Order> _cache = new();
    private readonly IOrderService _inner;

    public Order GetOrder(int id) =>
        _cache.GetOrAdd(id, key => _inner.GetOrder(key));  // atomic check-and-add
}

The golden rule: match your decorator's DI lifetime to its thread-safety guarantees. A Singleton decorator must be thread-safe (multiple threads share it). A Scoped decorator gets one instance per HTTP request — usually safe because web requests are single-threaded within a scope.

Great Answer Bonus "A common gotcha: a singleton CachingDecorator wrapping a scoped OrderService. The scoped service gets captured by the singleton and lives forever — this is the captive dependency anti-pattern. The DI container should warn about this."
Think First What happens when the interface gets a new method and you have 6 decorators?

Imagine you have 6 decorators (logging, caching, retry, metrics, auth, validation), and your IOrderService interface has 5 methods. Without a base decorator, each of those 6 decorators must manually forward all 5 methods — that's 30 forwarding methods to write and maintain. Now add a new method to the interface? You need to update all 6 decorators. Miss one? The compiler catches it (it won't compile), but now you're doing the same copy-paste forwarding in 6 files.

A base decorator class solves this by forwarding everything by default. Your concrete decorators inherit from it and override only the methods they care about:

BaseDecorator.cs
// Base decorator — forwards everything to inner by default
public abstract class OrderServiceDecorator : IOrderService
{
    protected readonly IOrderService _inner;
    protected OrderServiceDecorator(IOrderService inner) => _inner = inner;

    public virtual Order GetOrder(int id)         => _inner.GetOrder(id);
    public virtual IList<Order> GetAll()          => _inner.GetAll();
    public virtual void PlaceOrder(Order order)    => _inner.PlaceOrder(order);
    public virtual void CancelOrder(int id)        => _inner.CancelOrder(id);
    public virtual OrderStats GetStats()           => _inner.GetStats();
}

// Concrete decorator — only overrides what it enhances (2 methods, not 5!)
public class LoggingOrderDecorator : OrderServiceDecorator
{
    private readonly ILogger _log;
    public LoggingOrderDecorator(IOrderService inner, ILogger log)
        : base(inner) => _log = log;

    public override Order GetOrder(int id)
    {
        _log.LogInformation("Getting order {Id}", id);
        return base.GetOrder(id);  // forwards to inner automatically
    }
    // GetAll, PlaceOrder, CancelOrder, GetStats — auto-forwarded by base!
}

When a new method is added to IOrderService, you add it once in the base decorator — all 6 concrete decorators inherit the forwarding automatically. Zero maintenance overhead.

Great Answer Bonus "In .NET, you can use source generators to auto-generate the base decorator class from the interface. Scrutor and some community libraries already do this. It eliminates the forwarding boilerplate entirely."

Hard Advanced & Production (Q13—Q29)

Think First What's the difference between Thread.Sleep() and Task.Delay() in an async context? Why does it matter for decorators?

A retry decorator re-attempts a failed operation a configurable number of times before giving up. It's one of the most useful decorators in production, especially for services that call external APIs (payment gateways, email providers, third-party integrations) where transient failures are common.

Key requirements for a production-grade retry decorator:

  • Async all the way — use await for inner calls and await Task.Delay() for backoff. Never .Result or Thread.Sleep() (see Bug Study #5)
  • Configurable retry count and delay strategy — fixed delay, exponential backoff, or jittered backoff to avoid thundering herd
  • Only retry specific exceptions — retry on HttpRequestException (network failure), not on ValidationException (bad input won't fix itself)
  • Log each attempt — so you can see "attempt 1 failed, retrying... attempt 2 failed, retrying... attempt 3 succeeded"
  • Re-throw the last exception — if all retries fail, don't swallow the error. The caller needs to know something went wrong.
RetryDecorator.cs
public class RetryPaymentDecorator : IPaymentGateway
{
    private readonly IPaymentGateway _inner;
    private readonly ILogger _logger;
    private readonly int _maxRetries;

    public RetryPaymentDecorator(IPaymentGateway inner, ILogger logger,
        int maxRetries = 3)
    {
        _inner = inner;
        _logger = logger;
        _maxRetries = maxRetries;
    }

    public async Task<PaymentResult> ChargeAsync(PaymentRequest request)
    {
        Exception? lastException = null;

        for (int attempt = 1; attempt <= _maxRetries; attempt++)
        {
            try
            {
                return await _inner.ChargeAsync(request); // ✅ await, not .Result
            }
            catch (HttpRequestException ex) // ✅ only retry network errors
            {
                lastException = ex;
                _logger.LogWarning("Attempt {Attempt}/{Max} failed: {Error}",
                    attempt, _maxRetries, ex.Message);

                if (attempt < _maxRetries)
                {
                    // ✅ Exponential backoff with jitter
                    var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt))
                        + TimeSpan.FromMilliseconds(Random.Shared.Next(0, 1000));
                    await Task.Delay(delay); // ✅ non-blocking wait
                }
            }
            // ❌ Don't catch ValidationException, ArgumentException, etc.
            // Those won't fix themselves on retry
        }

        throw lastException!; // ✅ re-throw — caller needs to know we failed
    }
}

Walking through the code: The decorator wraps the inner payment gateway and tries calling it up to _maxRetries times. If the call succeeds, it returns immediately. If it throws an HttpRequestException (network/server error), it logs the failure, waits using exponential backoff (2s, 4s, 8s) with random jitter (to prevent all retries hitting the server at the same instant), and tries again. If all retries fail, it throws the last exception so the caller can handle it. Notice: it only catches network errors — a ValidationException means the input is wrong, and retrying won't fix bad data.

Great Answer Bonus "In production, I'd use Polly instead of hand-rolling retry. But understanding how to build one is important — Polly itself uses the Decorator pattern internally. Its RetryPolicy wraps your delegate with retry logic."
Think First When you call services.Decorate<IService, Decorator>(), what happens to the existing IService registration?

When you call services.Decorate<IOrderService, LoggingDecorator>(), Scrutor does something clever behind the scenes. It doesn't just "add" your decorator — it performs a registration replacement:

  1. Find the existing ServiceDescriptor for IOrderService in the service collection
  2. Remove that registration
  3. Create a new registration — a factory lambda that first resolves the original implementation (captured in a closure), then passes it as the inner constructor parameter to your decorator
  4. Preserve the lifetime — if OrderService was registered as Scoped, the decorator is also Scoped
services.Decorate<IOrderService, LoggingDecorator>() Before: Service Collection IOrderService → OrderService (Scoped) Decorate() After: Service Collection IOrderService → Factory Lambda (Scoped — lifetime preserved) At resolution time, the factory does: ① Resolve OrderService ② new LoggingDecorator(inner) ③ Return

When you chain multiple Decorate calls, each one wraps around the previous result. So Decorate<A>() then Decorate<B>() means the factory creates: B(A(RealService)). The last one registered is outermost.

Great Answer Bonus "The tricky part is preserving the original service's lifetime. If OrderService was scoped, the decorator should also be scoped. Scrutor handles this by copying the lifetime from the original ServiceDescriptor to the new one."
Think First If you register decorators in order A, B, C with Scrutor, which one is outermost?

With Scrutor, the last registered decorator is outermost. If you do Decorate<A>() then Decorate<B>() then Decorate<C>(), the chain is C → B → A → RealService. C runs first, then delegates to B, and so on. The recommended order: outermost = observability (logging, metrics), middle = resilience (retry, circuit breaker), innermost = data (caching, validation). Document the order with comments in your DI setup.

Registration order → Last registered = Outermost (runs first) Logging (sees everything) Metrics (counts calls) Retry (handles failures) Caching (avoids work) Service Outermost ↑ registered LAST Innermost ↑ registered FIRST
Great Answer Bonus "To make order explicit and prevent accidental reordering, I create an extension method like AddOrderServiceWithDecorators() that registers everything in the correct order with inline comments. This also prevents the duplicate-registration bug."
Think First Each decorator layer adds a virtual method call. How many nanoseconds does a virtual call take? Does it matter for a method that hits a database?

Let's put real numbers on this. Each decorator layer adds one virtual method call — approximately 2-5 nanoseconds on modern hardware. To put that in perspective:

  • A database query takes ~1-10 milliseconds (1,000,000+ nanoseconds)
  • An HTTP call takes ~50-500 milliseconds
  • Even 10 decorator layers add only ~50 nanoseconds total

So for any method that does I/O (which is almost every real service method), decorator overhead is less than 0.001% of the total time — completely invisible.

The real performance concern isn't the virtual dispatch — it's object allocation. If decorators are registered as Transient (new instance per request), a 5-layer chain creates 5 objects per request. At 10,000 requests/second, that's 50,000 small objects entering Gen 0 garbage collection every second. The fix is simple: register decorators as Singleton or Scoped. Singletons are allocated once and shared. Scoped decorators are allocated once per request scope and reused within it.

The only scenario where decorator overhead truly matters is in tight computational loops — math functions called 100 million times per second with zero I/O. In those cases, every nanosecond counts and you should inline the logic. But if your method touches a database, file system, or network? Add as many decorators as you need. The maintainability benefit dwarfs the nanosecond cost.

Great Answer Bonus "I benchmark with BenchmarkDotNet before and after adding decorators. In every real-world case I've measured, the decorator overhead was invisible compared to the business logic and I/O time. The maintainability benefit far outweighs the nanosecond cost."
Think First Can an interceptor add behavior without implementing the target's interface?

Both achieve the same goal — adding behavior around method calls — but they work completely differently under the hood.

Decorators are compile-time, explicit. You write a real class that implements the interface. You can see the code, step through it in the debugger, and test it like any other class. The downside: you must write a decorator class for each interface you want to wrap, and forward all methods you don't override.

Interceptors are runtime, implicit. A framework (Castle DynamicProxy, DispatchProxy) generates a proxy class at runtime using reflection or IL generation. You write one interceptor method that handles all method calls on any interface. No forwarding boilerplate, no interface-specific code. The downside: harder to debug (you're stepping through framework-generated code), higher runtime overhead, and the "magic" is invisible in your codebase.

Decorator 📝 You write the wrapper class 🔍 Fully debuggable (step through) ⚡ Zero runtime overhead 📋 One class per interface Best for: specific services (3-5) Interceptor 🤖 Framework generates proxy 🔮 Harder to debug (magic code) ⏱️ Reflection/IL overhead ✨ One interceptor for ALL interfaces Best for: many services (50+)

When to use which: If the concern applies to 3-5 specific services and you want full control, use explicit Decorators. If the same concern applies uniformly to 50+ services (like adding trace logging to everything), Interceptors save massive boilerplate.

Great Answer Bonus "In .NET, Castle DynamicProxy is the most popular interception library. Libraries like MediatR's IPipelineBehavior use a form of interception. I prefer explicit decorators for domain services and interceptors for infrastructure concerns that apply uniformly across many services."
Think First Can you write one LoggingDecorator<T> that works for IRepository<Order>, IRepository<Customer>, and any other entity?

Yes — and this is one of Decorator's most powerful features. Instead of writing a separate logging decorator for IOrderRepository, ICustomerRepository, IProductRepository, you write one generic decorator that works for all of them. Think of it like a universal gift wrapper that fits any box size — you write the wrapping logic once, and it adapts to whatever's inside.

GenericDecorator.cs
// The generic interface
public interface IRepository<T> where T : class
{
    Task<T?> GetByIdAsync(int id);
    Task<IList<T>> GetAllAsync();
    Task SaveAsync(T entity);
}

// ONE decorator that works for ALL entity types
public class LoggingRepository<T> : IRepository<T> where T : class
{
    private readonly IRepository<T> _inner;
    private readonly ILogger<LoggingRepository<T>> _log;

    public LoggingRepository(IRepository<T> inner, ILogger<LoggingRepository<T>> log)
        => (_inner, _log) = (inner, log);

    public async Task<T?> GetByIdAsync(int id)
    {
        _log.LogInformation("Getting {Type} with ID {Id}", typeof(T).Name, id);
        return await _inner.GetByIdAsync(id);
    }
    // ... same pattern for GetAllAsync and SaveAsync
}

// Register with Scrutor — open generic means it applies to ALL repositories
services.Decorate(typeof(IRepository<>), typeof(LoggingRepository<>));
// Now IRepository<Order>, IRepository<Customer>, IRepository<Product>
// ALL get automatic logging — zero extra code per entity!

This is exactly how MediatR's pipeline behaviors work. IPipelineBehavior<TRequest, TResponse> is a generic decorator around IRequestHandler<TRequest, TResponse>. You write one validation behavior and it applies to every handler in your app.

Great Answer Bonus "Open generic decoration is how MediatR's pipeline behaviors work. IPipelineBehavior<TRequest, TResponse> is essentially a generic decorator around IRequestHandler<TRequest, TResponse>. You write one behavior class and it applies to every handler."
Think First Logging, caching, authorization — these apply to many services. Is Decorator or AOP (Aspect-Oriented Programming) better?

Cross-cutting concerns are behaviors that apply to many services but aren't part of the core business logic — things like logging, caching, authorization, retry. The question is: how do you apply them without copy-pasting the same code into 50 services?

Decorator approach: You manually write a wrapper class for each concern. LoggingDecorator wraps IOrderService and adds logging. It's explicit — you can see exactly what happens, set breakpoints, and debug normally. The trade-off: you write more code, and each service that needs decoration must be explicitly wrapped.

AOP approach (PostSharp, Fody, Castle DynamicProxy): You add an attribute like [Log] to a method, and the framework automatically injects logging behavior — either at compile time (PostSharp weaves IL into your assembly) or at runtime (DynamicProxy creates a subclass on-the-fly). Less code to write, but the behavior is "magic" — harder to debug because the injected code isn't visible in your source.

DecoratorVsAOP.cs
// Decorator approach — explicit, debuggable
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, LoggingDecorator>();    // visible in code
services.Decorate<IOrderService, CachingDecorator>();    // you control the order

// AOP approach — attribute-based, less code
[Log]        // ← PostSharp weaves logging at compile time
[Cache(60)]  // ← you don't see HOW it's implemented
public Order GetOrder(int id) => _db.Orders.Find(id);

In modern .NET, the trend has strongly moved toward explicit Decorator + DI over magic AOP. MediatR pipeline behaviors, HttpClient DelegatingHandlers, and ASP.NET middleware are all Decorator-style patterns. The community consensus: if the concern applies to 3-5 services, use explicit decorators. If it applies to 50+ services identically, consider source generators to reduce boilerplate.

Great Answer Bonus "My rule: if the concern applies to 3-5 services, use explicit decorators. If it applies to 50+ services identically, consider interceptors or source generators to avoid boilerplate. I avoid PostSharp-style compile-time weaving because it makes debugging difficult."
Think First Should you test each decorator with the real service or with a mock?

Decorators are one of the easiest patterns to test, because each decorator takes an interface in its constructor — which means you can pass in a mock. No special test infrastructure needed. Just "new up" the decorator with a mock inner, call the method, and assert.

Use a three-layer testing strategy:

  1. Unit test each decorator in isolation — mock the inner service, verify the decorator adds its specific behavior. This is the most important layer.
  2. Integration test the full chain — stack all decorators on a real (or in-memory) service, verify end-to-end behavior.
  3. DI configuration test — resolve the service from the real container, verify the chain is wired correctly.
DecoratorTests.cs
// Layer 1: Unit test — test the CachingDecorator in isolation
[Fact]
public async Task CachingDecorator_SecondCall_ReturnsCached()
{
    var mockInner = new Mock<IOrderService>();
    mockInner.Setup(s => s.GetOrder(42)).Returns(new Order { Id = 42 });
    var decorator = new CachingDecorator(mockInner.Object);

    var first  = decorator.GetOrder(42);  // calls inner
    var second = decorator.GetOrder(42);  // should return cached

    mockInner.Verify(s => s.GetOrder(42), Times.Once()); // inner called ONCE, not twice
    Assert.Same(first, second);  // same cached object returned
}

// Layer 2: Integration test — verify full chain works together
[Fact]
public async Task FullChain_LogsCachesAndReturnsOrder()
{
    var realService = new InMemoryOrderService();
    var cached  = new CachingDecorator(realService);
    var logged  = new LoggingDecorator(cached, testLogger);
    var result  = logged.GetOrder(1);
    Assert.NotNull(result);
    Assert.Contains("Getting order", testLogger.Messages);
}

// Layer 3: DI test — verify chain is wired correctly
[Fact]
public void DI_ResolvesDecoratorChain()
{
    var provider = CreateServiceProvider(); // your real DI setup
    var service  = provider.GetRequiredService<IOrderService>();
    Assert.IsType<LoggingDecorator>(service);   // outermost = logging
}
Great Answer Bonus "The beauty of Decorator for testing: because each decorator takes an interface in its constructor, it's naturally mockable. You don't need any special test infrastructure — just new up the decorator with a mock inner, call the method, and assert."
Think First Each decorator is an object on the heap. How much memory does an object with one field (the inner reference) consume in .NET?

Let's break down the actual numbers. In .NET on a 64-bit system, every object has a fixed overhead:

  • 8 bytes — object header (used by GC and locking)
  • 8 bytes — method table pointer (how .NET knows the object's type)
  • 8 bytes — the _inner field (reference to the wrapped service)

That's 24 bytes minimum per decorator. Add fields for injected dependencies (ILogger = 8 bytes, IMemoryCache = 8 bytes), and a typical decorator uses 32-48 bytes. A 5-layer chain? About 200-300 bytes total — less than a single string like "Hello, World!" takes in memory.

For Singleton or Scoped registration, this memory is allocated once and shared across all callers. Memory impact: effectively zero.

The concern arises only with Transient registration at high throughput. If every request creates a fresh 5-layer chain, at 10,000 requests/second that's 50,000 short-lived objects per second entering Gen 0 garbage collection. Gen 0 collections are fast (~1ms), but at extreme scale this adds GC pressure. The fix: register decorators as Scoped (one per request scope, reused within it) or Singleton (one for the entire application lifetime).

In practice, decorator memory has never been a production issue in any real-world project. The internal state of decorators (caches holding thousands of entries, buffers holding MB of data) dwarfs the wrapper objects themselves.

Great Answer Bonus "In practice, I've never seen decorator memory be a production issue. The total memory for a decorator chain is less than a single string allocation. Focus on the decorators' internal state (caches, buffers) rather than the wrapper objects themselves."
Think First If the outermost decorator is disposed, should it dispose the inner one? What if the inner is shared?

Disposal in a decorator chain is about answering one question: who created it, who cleans it up? This is the "ownership" rule — the object that created a resource is responsible for destroying it.

There are two scenarios:

DI-managed chains (most common): The DI container created each layer, so the container disposes each layer independently when the scope ends. Your decorators do NOT need to dispose the inner service — the container handles it. If you dispose the inner yourself, you might cause a double-dispose crash.

Manually-built chains (like .NET Streams): You created the chain, so you must clean it up. Each decorator should implement IDisposable and propagate Dispose to the inner object:

DisposalChain.cs
// For manual chains: propagate Dispose with try/finally
public class EncryptingStream : Stream, IDisposable
{
    private readonly Stream _inner;
    private readonly bool _leaveOpen;  // ← the escape hatch

    public EncryptingStream(Stream inner, bool leaveOpen = false)
        => (_inner, _leaveOpen) = (inner, leaveOpen);

    protected override void Dispose(bool disposing)
    {
        if (disposing && !_leaveOpen)
            _inner.Dispose();  // only dispose inner if we OWN it
        base.Dispose(disposing);
    }
}

// Usage — leaveOpen controls ownership:
using var fileStream = File.OpenRead("data.bin");
using var buffered   = new BufferedStream(fileStream);
using var encrypted  = new EncryptingStream(buffered);
// When 'encrypted' disposes, it disposes 'buffered',
// which disposes 'fileStream'. Clean chain.

// For DI-managed chains — do NOT dispose inner:
public class LoggingDecorator : IOrderService  // no IDisposable needed
{
    private readonly IOrderService _inner;  // DI container owns this
    // ... just delegate calls, don't touch disposal
}

A subtle gotcha: if a CachingDecorator caches results containing IDisposable objects, who disposes the cached items when they're evicted? Use IMemoryCache's PostEvictionCallback to handle this — otherwise you'll leak resources silently.

Great Answer Bonus "A subtle issue: if a CachingDecorator caches results that contain IDisposable objects, who disposes the cached items? The cache eviction callback should handle disposal. Use IMemoryCache's PostEvictionCallback for this."
Think First When Service A calls Service B over HTTP, where would decorators fit?

Decorators wrap the client-side service abstraction. Define IPaymentGateway as an interface. The concrete implementation makes HTTP calls to the payment service. Wrap it with decorators: RetryDecorator (handles transient failures), CircuitBreakerDecorator (stops calling when service is down), CachingDecorator (caches idempotent responses), MetricsDecorator (records latency and error rates). In .NET, HttpClient's DelegatingHandler chain is Decorator at the HTTP level — Polly integrates here via AddPolicyHandler().

Your Service calls IPayment Gateway Decorator Chain (in your service) Metrics Retry Breaker Cache PaymentGatewayHttpClient (makes HTTP call) + DelegatingHandler chain for HTTP concerns Payment Service (remote)
Great Answer Bonus "I use both levels: DelegatingHandlers for HTTP-level concerns (retry, timeout, correlation ID headers) and service-level decorators for business concerns (caching domain objects, logging business events). They compose independently."
Think First What's the main boilerplate problem with Decorator? Could a source generator solve it?

The biggest pain point with Decorator is the forwarding boilerplate — if your interface has 10 methods, your base decorator must forward all 10, even though most concrete decorators only override 1-2 of them. Source generatorsSource generators are a C# compiler feature that generates additional source code at compile time based on your existing code. They analyze your types via Roslyn and emit new .cs files that become part of your compilation. No runtime reflection, no IL weaving — just generated code that's visible and debuggable. eliminate this entirely.

A source generator reads your interface at compile time, generates the forwarding base class automatically, and you just write the override for the methods you want to enhance:

SourceGenDecorator.cs
// Step 1: Mark your interface for generation
[GenerateDecorator]  // ← source generator attribute
public interface IOrderService
{
    Task<Order> GetOrderAsync(int id);
    Task<IList<Order>> GetAllAsync();
    Task PlaceOrderAsync(Order order);
    Task CancelOrderAsync(int id);
    Task<OrderStats> GetStatsAsync();
}

// Step 2: The source generator auto-creates this at compile time:
// (you never write this — it's generated into obj/Generated/)
public abstract partial class OrderServiceDecoratorBase : IOrderService
{
    protected readonly IOrderService _inner;
    protected OrderServiceDecoratorBase(IOrderService inner) => _inner = inner;
    public virtual Task<Order> GetOrderAsync(int id) => _inner.GetOrderAsync(id);
    public virtual Task<IList<Order>> GetAllAsync() => _inner.GetAllAsync();
    // ... all 5 methods auto-forwarded
}

// Step 3: You write only the override you care about
public class LoggingDecorator : OrderServiceDecoratorBase
{
    public LoggingDecorator(IOrderService inner, ILogger log) : base(inner) { }

    public override async Task<Order> GetOrderAsync(int id)
    {
        _log.LogInformation("Getting order {Id}", id);
        return await base.GetOrderAsync(id);
    }
    // Other 4 methods: auto-forwarded by the generated base class!
}

The generated code has zero runtime overhead — no reflection, no proxies, no IL weaving. It's just plain C# that the compiler produces. You can inspect it in the obj/Generated/ folder, set breakpoints in it, and debug it normally. If the interface changes (method added/removed), the generator updates the base class automatically at compile time.

Great Answer Bonus "Source generators eliminate the two main Decorator complaints: forwarding boilerplate and the risk of missing a method. The generated base class always matches the interface — if the interface changes, the generator updates the base automatically at compile time."
Think First How would you add distributed tracing spans around every service call using Decorator?

OpenTelemetry gives you distributed tracing — the ability to follow a single request across services and see exactly where time is spent. A TracingDecorator wraps your service and creates a "span" (a timed block) around each method call, recording what happened, how long it took, and whether it succeeded.

In .NET, spans are represented by the Activity class (from System.Diagnostics). Your TracingDecorator creates an Activity before calling the inner service, adds tags with useful context, and marks success or failure when the call completes:

TracingDecorator.cs
public class TracingOrderDecorator : IOrderService
{
    private static readonly ActivitySource Source = new("MyApp.Orders");
    private readonly IOrderService _inner;

    public TracingOrderDecorator(IOrderService inner) => _inner = inner;

    public async Task<Order> GetOrderAsync(int id)
    {
        using var activity = Source.StartActivity("GetOrder");
        activity?.SetTag("order.id", id);  // searchable in Jaeger/Zipkin

        try
        {
            var result = await _inner.GetOrderAsync(id);
            activity?.SetTag("order.found", result != null);
            activity?.SetStatus(ActivityStatusCode.Ok);
            return result;
        }
        catch (Exception ex)
        {
            activity?.SetStatus(ActivityStatusCode.Error, ex.Message);
            activity?.RecordException(ex);  // exception details in the trace
            throw;
        }
    }
}

// Register as OUTERMOST decorator — so it traces the full chain
// (including retry attempts, cache hits, etc.)
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, CachingDecorator>();
services.Decorate<IOrderService, RetryDecorator>();
services.Decorate<IOrderService, TracingOrderDecorator>(); // outermost = registered last

Pair this with a MetricsDecorator that records histograms (latency percentiles) and counters (success/failure rates), and you get full observability without touching any business logic — pure Decorator benefit.

Great Answer Bonus "I combine the TracingDecorator with a MetricsDecorator that records histograms (latency) and counters (success/failure). Both are registered as outermost decorators. This gives me full observability without touching any business logic — pure Decorator benefit."
Think First Beyond logging and caching, what other real-world concerns can be handled with Decorator?

Here are decorator patterns that I've used (or seen used) in real production .NET systems. The common thread: each concern is optional, independently toggleable, and has nothing to do with core business logic — exactly what Decorator is designed for:

  • Retry + exponential backoff with jitter — wraps any service that calls external APIs. Configurable max retries and delay per endpoint. Handles transient HTTP 500s, timeouts, and network blips without the caller knowing.
  • Circuit breaker — tracks failure rate. After N consecutive failures, it "opens" and immediately returns an error (or fallback) instead of calling the failing service. After a timeout, it "half-opens" and lets one request through to test if the service recovered. Prevents cascade failures in microservices.
  • Distributed cache (Redis-backed) — caches responses with configurable TTL per method. The decorator checks Redis first; if there's a hit, returns cached data without calling the inner service. Cache misses go through to the real service and cache the result.
  • Rate limiting — per-tenant throttling for multi-tenant SaaS. If Tenant A exceeds their quota, the decorator returns HTTP 429 without touching the service. Other tenants are unaffected.
  • Audit logging — records who called what, when, with before/after snapshots of the data. Critical for compliance (GDPR, SOX). The decorator captures the state before the inner call, then the state after, and writes both to an audit trail.
  • Feature flags — decorator that checks a feature flag service. If the feature is disabled, it short-circuits to a fallback behavior instead of calling the new implementation. Enables safe gradual rollouts.

In a typical production service, you might stack 3-4 of these: TracingDecorator → RetryDecorator → CachingDecorator → RealService. Each one is independently testable, independently removable, and none knows about the others.

Great Answer Bonus "The common thread: each of these is optional, independently toggleable, and has nothing to do with the core business logic. That's the Decorator sweet spot — orthogonal concerns that compose."
Think First All three wrap behavior around a core operation. What distinguishes them?

The distinctions:

  • Decorator: wraps a specific interface. Type-safe, compile-time checked, works per-service. Best for: service-level concerns (caching IOrderService, retrying IPaymentGateway)
  • Middleware: wraps a generic request/response pipeline. Runs for ALL requests. Best for: infrastructure concerns (auth, CORS, compression, request logging)
  • Pipeline (MediatR behaviors): wraps a generic handler with typed request/response. Best for: cross-cutting concerns across all handlers (validation, logging, transaction management)
Decorator Wraps: specific interface Scope: per-service LoggingDecorator CachingDecorator OrderService Middleware Wraps: HTTP pipeline Scope: ALL requests AuthMiddleware CorsMiddleware CompressionMiddleware Pipeline Wraps: typed handlers Scope: per-handler type ValidationBehavior LoggingBehavior TransactionBehavior
Great Answer Bonus "I use all three in the same application. ASP.NET middleware for HTTP concerns, MediatR pipeline behaviors for CQRS concerns, and explicit decorators for specific service concerns. They operate at different abstraction levels and compose independently."
Think First A method call goes through 5 decorator layers and the result is wrong. How do you figure out which layer caused the issue?

When a method call goes through 5 decorator layers and the result is wrong, the challenge is figuring out which layer caused the problem. Is the caching decorator returning stale data? Is the retry decorator masking an error? Is the real service returning bad data? Here's a systematic approach:

Four debugging strategies:

  1. Structured logging in every decorator — each decorator logs entry/exit with its class name. The log sequence tells you exactly which layers ran and in what order. If the logs show "CachingDecorator: returning cached result" when you expected a fresh DB call, you've found the culprit.
  2. Correlation IDs — pass a unique ID through the chain so you can filter logs for one specific call across all layers. Without this, high-traffic logs become impossible to follow.
  3. Bypass decorators — in development, resolve the concrete service directly (skip DI decoration) to test if the issue is in a decorator or the real service.
  4. Unit tests per decorator — if each decorator passes its isolated tests, the bug is likely in the interaction between layers (ordering, state sharing).

A powerful diagnostic technique: add a startup log that dumps the full chain structure so you never have to guess:

ChainDiagnostic.cs
// Log the full decorator chain at startup
public static void LogDecoratorChain<T>(IServiceProvider sp, ILogger log)
{
    var service = sp.GetRequiredService<T>();
    var chain = new List<string>();
    var current = service as object;

    while (current != null)
    {
        chain.Add(current.GetType().Name);
        // Try to read the "_inner" field via reflection (dev only!)
        var innerField = current.GetType()
            .GetField("_inner", BindingFlags.NonPublic | BindingFlags.Instance);
        current = innerField?.GetValue(current);
    }

    log.LogInformation("IOrderService chain: {Chain}", string.Join(" → ", chain));
    // Output: "IOrderService chain: LoggingDecorator → RetryDecorator →
    //          CachingDecorator → OrderService"
}
Great Answer Bonus "I add a diagnostic middleware/decorator that logs the full chain at startup: 'IOrderService resolved as: Logging → Retry → Caching → OrderService'. This immediately tells me the chain structure without debugger inspection."
Think First You have an IPaymentGateway that needs: logging, retry with backoff, circuit breaker, metrics, distributed tracing, and tenant-based rate limiting. How do you structure the decorator chain?

Decorator chain from outermost to innermost:

  1. TracingDecorator (outermost) — creates OpenTelemetry span, captures full call duration including retries
  2. MetricsDecorator — records latency histogram, success/failure counters
  3. LoggingDecorator — logs entry/exit, method args, correlation ID
  4. RateLimitingDecorator — per-tenant rate limiting, throws 429 if exceeded
  5. CircuitBreakerDecorator — opens after 5 failures, half-open after 30s
  6. RetryDecorator — 3 retries with exponential backoff + jitter
  7. PaymentGatewayHttpClient (innermost, concrete) — makes the actual HTTP call
IPaymentGateway — Full Production Chain 1. Tracing (sees everything) 2. Metrics (counts calls) 3. Logging (records details) 4. Rate Limiting (per-tenant throttle) 5. Circuit Breaker (stops calling dead services) 6. Retry (3× with backoff) 7. PaymentGatewayHttpClient → HTTP Request flows IN → Response flows OUT ←

Why this order? Tracing sees everything including retries. Metrics count at the business level (one charge attempt = one metric even if retried). Logging captures the full story. Rate limiting stops excessive calls before they hit retry logic. Circuit breaker prevents calling a dead service. Retry handles transient failures closest to the actual call.

Great Answer Bonus "In production, I'd implement Retry and Circuit Breaker via Polly (wrapped as decorators or DelegatingHandlers) rather than hand-rolling. The tracing, metrics, and logging decorators are custom because they need business-specific context. Registration: one extension method, AddPaymentGateway(), that wires the entire chain with comments documenting the order and rationale."
Section 18

Practice Exercises

Four exercises to cement your Decorator skills. Start with the Stream exercise to understand the core wrapping mechanism, then build progressively more complex decorators. The last exercise is a bug hunt — find and fix 4 hidden bugs in a decorator chain.

Exercise 1: Build a Stream Decorator Medium

Create a CountingStream that wraps any Stream and counts the total bytes read and written. The class should inherit from Stream, forward all abstract members to the inner stream, and maintain running counters for TotalBytesRead and TotalBytesWritten. Write unit tests that verify the counts are accurate after multiple read/write operations.

  • Inherit from Stream (not just implement an interface) — this is how .NET stream decorators work
  • Override Read(byte[] buffer, int offset, int count) — call _inner.Read(), capture the return value (actual bytes read), add it to your counter, then return the value
  • Override Write(byte[] buffer, int offset, int count) — call _inner.Write(), add count to your written counter
  • Forward all other abstract members: CanRead, CanWrite, CanSeek, Length, Position, Flush(), Seek(), SetLength()
  • Expose long TotalBytesRead { get; } and long TotalBytesWritten { get; } as public properties
  • Use Interlocked.Add() if you need thread safety
CountingStream.cs
public class CountingStream : Stream
{
    private readonly Stream _inner;
    private long _totalBytesRead;
    private long _totalBytesWritten;

    public CountingStream(Stream inner)
        => _inner = inner ?? throw new ArgumentNullException(nameof(inner));

    public long TotalBytesRead => Interlocked.Read(ref _totalBytesRead);
    public long TotalBytesWritten => Interlocked.Read(ref _totalBytesWritten);

    public override int Read(byte[] buffer, int offset, int count)
    {
        int bytesRead = _inner.Read(buffer, offset, count);
        Interlocked.Add(ref _totalBytesRead, bytesRead);
        return bytesRead;
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        _inner.Write(buffer, offset, count);
        Interlocked.Add(ref _totalBytesWritten, count);
    }

    // Forward all abstract members
    public override bool CanRead => _inner.CanRead;
    public override bool CanSeek => _inner.CanSeek;
    public override bool CanWrite => _inner.CanWrite;
    public override long Length => _inner.Length;
    public override long Position
    {
        get => _inner.Position;
        set => _inner.Position = value;
    }
    public override void Flush() => _inner.Flush();
    public override long Seek(long offset, SeekOrigin origin)
        => _inner.Seek(offset, origin);
    public override void SetLength(long value)
        => _inner.SetLength(value);

    protected override void Dispose(bool disposing)
    {
        if (disposing) _inner.Dispose();
        base.Dispose(disposing);
    }
}

// Unit test
public class CountingStreamTests
{
    [Fact]
    public void Read_CountsBytes()
    {
        var data = new byte[] { 1, 2, 3, 4, 5 };
        using var inner = new MemoryStream(data);
        using var counting = new CountingStream(inner);

        var buffer = new byte[3];
        int read1 = counting.Read(buffer, 0, 3); // reads 3 bytes
        int read2 = counting.Read(buffer, 0, 3); // reads 2 bytes (end of stream)

        Assert.Equal(3, read1);
        Assert.Equal(2, read2);
        Assert.Equal(5, counting.TotalBytesRead);
        Assert.Equal(0, counting.TotalBytesWritten);
    }

    [Fact]
    public void Write_CountsBytes()
    {
        using var inner = new MemoryStream();
        using var counting = new CountingStream(inner);

        counting.Write(new byte[] { 1, 2, 3 }, 0, 3);
        counting.Write(new byte[] { 4, 5 }, 0, 2);

        Assert.Equal(0, counting.TotalBytesRead);
        Assert.Equal(5, counting.TotalBytesWritten);
    }
}
Exercise 2: Logging Decorator with DI Medium

Create a LoggingOrderService that wraps IOrderService and logs method entry, exit, and timing for every call. Use ILogger<LoggingOrderService> for logging and Stopwatch for timing. Register everything with Scrutor and write tests using a mock inner service and FakeLogger.

  • Implement IOrderService (the same interface as the real service)
  • Constructor takes two parameters: IOrderService inner and ILogger<LoggingOrderService> logger
  • In each method: log entry with method name + arguments, start Stopwatch, call _inner.Method(), log exit with elapsed time
  • Wrap the inner call in try/catch — log errors with LogError() then re-throw (don't swallow exceptions)
  • DI registration: services.AddScoped<IOrderService, OrderService>(); then services.Decorate<IOrderService, LoggingOrderService>();
  • For testing: use Microsoft.Extensions.Logging.Testing.FakeLogger or create your own fake that captures log messages
LoggingOrderService.cs
public interface IOrderService
{
    Task<Order> CreateAsync(CreateOrderRequest request);
    Task CancelAsync(Guid orderId);
    Task<Order?> GetByIdAsync(Guid orderId);
}

public class LoggingOrderService : IOrderService
{
    private readonly IOrderService _inner;
    private readonly ILogger<LoggingOrderService> _logger;

    public LoggingOrderService(IOrderService inner,
        ILogger<LoggingOrderService> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public async Task<Order> CreateAsync(CreateOrderRequest request)
    {
        _logger.LogInformation("Creating order for customer {Id}",
            request.CustomerId);
        var sw = Stopwatch.StartNew();
        try
        {
            var order = await _inner.CreateAsync(request);
            sw.Stop();
            _logger.LogInformation(
                "Order {OrderId} created in {Elapsed}ms",
                order.Id, sw.ElapsedMilliseconds);
            return order;
        }
        catch (Exception ex)
        {
            sw.Stop();
            _logger.LogError(ex,
                "CreateOrder failed after {Elapsed}ms",
                sw.ElapsedMilliseconds);
            throw; // ✅ always re-throw
        }
    }

    public async Task CancelAsync(Guid orderId)
    {
        _logger.LogInformation("Cancelling order {OrderId}", orderId);
        var sw = Stopwatch.StartNew();
        try
        {
            await _inner.CancelAsync(orderId);
            _logger.LogInformation(
                "Order {OrderId} cancelled in {Elapsed}ms",
                orderId, sw.ElapsedMilliseconds);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex,
                "CancelOrder {OrderId} failed after {Elapsed}ms",
                orderId, sw.ElapsedMilliseconds);
            throw;
        }
    }

    public async Task<Order?> GetByIdAsync(Guid orderId)
    {
        _logger.LogDebug("Getting order {OrderId}", orderId);
        var sw = Stopwatch.StartNew();
        var order = await _inner.GetByIdAsync(orderId);
        _logger.LogDebug(
            "GetById {OrderId} returned {Found} in {Elapsed}ms",
            orderId, order is not null, sw.ElapsedMilliseconds);
        return order;
    }
}

// DI Registration
services.AddScoped<IOrderService, OrderService>();
services.Decorate<IOrderService, LoggingOrderService>();
Exercise 3: Retry + Circuit Breaker Decorators Hard

Build two decorators for IPaymentGateway: a RetryDecorator that retries failed calls 3 times with exponential backoff (1s, 2s, 4s), and a CircuitBreakerDecorator that opens the circuit after 5 consecutive failures and automatically tries again (half-open) after 30 seconds. Both must be fully async. Wire them so retry is innermost and circuit breaker wraps it.

  • RetryDecorator: Use a for-loop with await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempt - 1))) for exponential backoff
  • Catch only transient exceptions (e.g., HttpRequestException, TimeoutException). Let ArgumentException and other non-transient errors pass through immediately
  • CircuitBreakerDecorator: Track state with an enum: Closed (normal), Open (blocking), HalfOpen (testing one request)
  • When Open: throw CircuitBrokenException immediately without calling inner
  • When HalfOpen: allow one request through. If it succeeds, close the circuit. If it fails, re-open
  • Use DateTimeOffset.UtcNow for timeout tracking. Use SemaphoreSlim(1,1) to protect state transitions in async code
  • DI order: Decorate<RetryDecorator>() then Decorate<CircuitBreakerDecorator>() — circuit breaker is outermost
RetryDecorator.cs
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(PaymentRequest request);
}

public class RetryDecorator : IPaymentGateway
{
    private readonly IPaymentGateway _inner;
    private readonly ILogger<RetryDecorator> _logger;
    private readonly int _maxRetries;

    public RetryDecorator(IPaymentGateway inner,
        ILogger<RetryDecorator> logger, int maxRetries = 3)
    {
        _inner = inner;
        _logger = logger;
        _maxRetries = maxRetries;
    }

    public async Task<PaymentResult> ChargeAsync(PaymentRequest request)
    {
        Exception? lastException = null;

        for (int attempt = 1; attempt <= _maxRetries; attempt++)
        {
            try
            {
                return await _inner.ChargeAsync(request);
            }
            catch (Exception ex) when (IsTransient(ex) && attempt < _maxRetries)
            {
                lastException = ex;
                var delay = TimeSpan.FromSeconds(Math.Pow(2, attempt - 1));
                _logger.LogWarning(ex,
                    "Attempt {Attempt}/{Max} failed. Retrying in {Delay}s",
                    attempt, _maxRetries, delay.TotalSeconds);
                await Task.Delay(delay);
            }
        }

        throw new PaymentException(
            $"All {_maxRetries} retries failed", lastException!);
    }

    private static bool IsTransient(Exception ex) => ex is
        HttpRequestException or TimeoutException or TaskCanceledException;
}
CircuitBreakerDecorator.cs
public enum CircuitState { Closed, Open, HalfOpen }

public class CircuitBreakerDecorator : IPaymentGateway
{
    private readonly IPaymentGateway _inner;
    private readonly ILogger<CircuitBreakerDecorator> _logger;
    private readonly int _failureThreshold;
    private readonly TimeSpan _openDuration;

    private CircuitState _state = CircuitState.Closed;
    private int _failureCount;
    private DateTimeOffset _openedAt;
    private readonly SemaphoreSlim _lock = new(1, 1);

    public CircuitBreakerDecorator(IPaymentGateway inner,
        ILogger<CircuitBreakerDecorator> logger,
        int failureThreshold = 5,
        int openDurationSeconds = 30)
    {
        _inner = inner;
        _logger = logger;
        _failureThreshold = failureThreshold;
        _openDuration = TimeSpan.FromSeconds(openDurationSeconds);
    }

    public CircuitState State => _state;

    public async Task<PaymentResult> ChargeAsync(PaymentRequest request)
    {
        await _lock.WaitAsync();
        try
        {
            if (_state == CircuitState.Open)
            {
                if (DateTimeOffset.UtcNow - _openedAt >= _openDuration)
                {
                    _state = CircuitState.HalfOpen;
                    _logger.LogInformation("Circuit half-open, testing...");
                }
                else
                {
                    throw new CircuitBrokenException(
                        "Circuit is open. Try again later.");
                }
            }
        }
        finally { _lock.Release(); }

        try
        {
            var result = await _inner.ChargeAsync(request);

            await _lock.WaitAsync();
            try
            {
                _failureCount = 0;
                if (_state == CircuitState.HalfOpen)
                {
                    _state = CircuitState.Closed;
                    _logger.LogInformation("Circuit closed — service recovered");
                }
            }
            finally { _lock.Release(); }

            return result;
        }
        catch (Exception ex)
        {
            await _lock.WaitAsync();
            try
            {
                _failureCount++;
                if (_failureCount >= _failureThreshold)
                {
                    _state = CircuitState.Open;
                    _openedAt = DateTimeOffset.UtcNow;
                    _logger.LogError(
                        "Circuit OPEN after {Count} failures", _failureCount);
                }
            }
            finally { _lock.Release(); }

            throw;
        }
    }
}

public class CircuitBrokenException : Exception
{
    public CircuitBrokenException(string message) : base(message) { }
}

// DI Registration (order matters!)
services.AddScoped<IPaymentGateway, StripePaymentGateway>();
services.Decorate<IPaymentGateway, RetryDecorator>();           // inner
services.Decorate<IPaymentGateway, CircuitBreakerDecorator>();  // outer
Exercise 4: Find the Bugs Hard

The code below has a decorator chain with 4 hidden bugs. Find all four, explain why each is a problem, and write the fix. Bugs can be in the decorator code, the DI registration, disposal, async handling, or interface forwarding.

BuggyChain.cs
public interface IEmailSender
{
    Task SendAsync(string to, string subject, string body);
    Task<EmailStatus> GetStatusAsync(Guid emailId);
}

public class SmtpEmailSender : IEmailSender, IDisposable
{
    private readonly SmtpClient _client = new();
    private readonly IStatusRepository _statusDb;
    public SmtpEmailSender(IStatusRepository statusDb) => _statusDb = statusDb;
    public async Task SendAsync(string to, string subject, string body)
        => await _client.SendMailAsync(new MailMessage("noreply@app.com", to, subject, body));
    public async Task<EmailStatus> GetStatusAsync(Guid emailId)
        => await _statusDb.GetAsync(emailId);
    public void Dispose() => _client.Dispose();
}

// Bug somewhere in this decorator...
public class RetryEmailDecorator : IEmailSender
{
    private readonly IEmailSender _inner;
    public RetryEmailDecorator(IEmailSender inner) => _inner = inner;

    public async Task SendAsync(string to, string subject, string body)
    {
        for (int i = 0; i < 3; i++)
        {
            try { _inner.SendAsync(to, subject, body).Wait(); return; } // 🐛
            catch { Thread.Sleep(1000); }                                // 🐛
        }
    }
    public Task<EmailStatus> GetStatusAsync(Guid emailId) => Task.FromResult(default(EmailStatus)); // 🐛
}

// Bug somewhere in this decorator...
public class LoggingEmailDecorator : IEmailSender, IDisposable
{
    private readonly IEmailSender _inner;
    private readonly ILogger _logger;
    public LoggingEmailDecorator(IEmailSender inner, ILogger logger)
    { _inner = inner; _logger = logger; }

    public async Task SendAsync(string to, string subject, string body)
    {
        _logger.LogInformation("Sending email to {To}", to);
        await _inner.SendAsync(to, subject, body);
    }
    public Task<EmailStatus> GetStatusAsync(Guid emailId) => _inner.GetStatusAsync(emailId);
    public void Dispose() { } // 🐛
}

// Registration
services.AddSingleton<IEmailSender, SmtpEmailSender>();
services.Decorate<IEmailSender, RetryEmailDecorator>();
services.Decorate<IEmailSender, LoggingEmailDecorator>();
  • Look at how the retry decorator calls the async method — is it awaiting correctly?
  • Check what happens between retry attempts — blocking or non-blocking wait?
  • Does every decorator forward every interface method to the inner service?
  • When the logging decorator is disposed, what happens to the inner chain?

Bug 1: .Wait() instead of await

In RetryEmailDecorator.SendAsync(), the code calls _inner.SendAsync().Wait() which blocks the thread synchronously. This can cause deadlocks under load in ASP.NET Core. Fix: Use await _inner.SendAsync(to, subject, body);

Bug 2: Thread.Sleep() instead of Task.Delay()

Between retries, Thread.Sleep(1000) blocks the thread. In an async method, this wastes a thread pool thread. Fix: Use await Task.Delay(1000);

Bug 3: GetStatusAsync() not forwarded in RetryDecorator

RetryEmailDecorator.GetStatusAsync() returns default(EmailStatus) instead of forwarding to _inner.GetStatusAsync(). All status queries return null/empty. Fix: return _inner.GetStatusAsync(emailId);

Bug 4: Dispose not propagated in LoggingDecorator

LoggingEmailDecorator.Dispose() does nothing. The inner SmtpEmailSender (which holds an SmtpClient) is never disposed, leaking the SMTP connection. Fix: public void Dispose() { if (_inner is IDisposable d) d.Dispose(); }

FixedChain.cs
// ✅ Fixed RetryEmailDecorator
public class RetryEmailDecorator : IEmailSender
{
    private readonly IEmailSender _inner;
    public RetryEmailDecorator(IEmailSender inner) => _inner = inner;

    public async Task SendAsync(string to, string subject, string body)
    {
        for (int i = 0; i < 3; i++)
        {
            try
            {
                await _inner.SendAsync(to, subject, body); // ✅ await
                return;
            }
            catch when (i < 2)
            {
                await Task.Delay(1000);                     // ✅ async delay
            }
        }
    }

    public Task<EmailStatus> GetStatusAsync(Guid emailId)
        => _inner.GetStatusAsync(emailId);                  // ✅ forwarded
}

// ✅ Fixed LoggingEmailDecorator
public class LoggingEmailDecorator : IEmailSender, IDisposable
{
    private readonly IEmailSender _inner;
    private readonly ILogger _logger;

    public LoggingEmailDecorator(IEmailSender inner, ILogger logger)
    { _inner = inner; _logger = logger; }

    public async Task SendAsync(string to, string subject, string body)
    {
        _logger.LogInformation("Sending email to {To}", to);
        await _inner.SendAsync(to, subject, body);
    }

    public Task<EmailStatus> GetStatusAsync(Guid emailId)
        => _inner.GetStatusAsync(emailId);

    public void Dispose()
    {
        if (_inner is IDisposable d) d.Dispose();           // ✅ propagated
    }
}
Section 19

Cheat Sheet

Everything you need in one scannable grid. Bookmark this section — it's the fastest reference when you're knee-deep in code and need a quick reminder of how Decorator works.

Basic Structure
IComponent (interface)
  → Operation()

ConcreteComponent
  → implements IComponent
  → the "real" object

Decorator (abstract)
  → implements IComponent
  → holds IComponent _inner
  → forwards calls to _inner

ConcreteDecorator
  → extends Decorator
  → adds behavior before/after
  → calls base.Operation()
Base Decorator Template
public abstract class
  ComponentDecorator : IComponent
{
  private readonly IComponent _inner;

  protected ComponentDecorator(
    IComponent inner)
    => _inner = inner;

  // Forward everything
  public virtual string Operation()
    => _inner.Operation();
}

// Concrete: override what you need
// Leave the rest forwarded
DI Registration (Scrutor)
// Register the real service
services.AddScoped<IOrderService,
  OrderService>();

// Wrap it with decorators
// (order = inside → outside)
services.Decorate<IOrderService,
  ValidationDecorator>();
services.Decorate<IOrderService,
  LoggingDecorator>();
services.Decorate<IOrderService,
  CachingDecorator>();

// Resolve: Caching → Logging
//   → Validation → Real
Stream Wrapping
// .NET streams ARE decorators
// Each wraps the one inside it:

using var file =
  new FileStream("data.bin", ...);
using var crypto =
  new CryptoStream(file, ...);
using var gzip =
  new GZipStream(crypto, ...);
using var buffered =
  new BufferedStream(gzip);

// Write to buffered →
//   gzip compresses →
//   crypto encrypts →
//   file writes to disk
Middleware Pipeline
// ASP.NET middleware = Decorator
// Each wraps the next delegate

app.Use(async (ctx, next) => {
  // BEFORE (decorate request)
  var sw = Stopwatch.StartNew();

  await next();  // forward to inner

  // AFTER (decorate response)
  ctx.Response.Headers.Add(
    "X-Elapsed",
    sw.ElapsedMilliseconds + "ms");
});

// Same shape: wrap, forward, add
Testing Decorators
// 1. Mock the inner dependency
var mockInner = new Mock<IService>();
mockInner.Setup(s => s.Process(data))
  .ReturnsAsync(result);

// 2. Create decorator with mock
var sut = new LoggingDecorator(
  mockInner.Object, logger);

// 3. Call & verify decorator logic
var output = await sut.Process(data);

// 4. Verify forwarding happened
mockInner.Verify(
  s => s.Process(data), Times.Once);

// 5. Verify decorator added behavior
Assert.Contains("log entry", ...)
Common Mistake
// WRONG: forgot to forward!
public class BadDecorator : IService
{
  private readonly IService _inner;

  public string GetName()
    => "decorated"; // OOPS!
  // Never called _inner.GetName()

  public int GetCount()
    => 42; // OOPS again!
  // Original behavior is LOST
}

// FIX: always call _inner first,
// then add your behavior on top.
// Use a base decorator class so
// you only override what you need.
When To Use
✓ Cross-cutting concerns
  (logging, caching, auth)
✓ Optional behaviors you can
  mix and match at runtime
✓ Runtime composition
  (stack decorators dynamically)
✓ Open/Closed Principle
  (extend without modifying)
✓ Wrapping 3rd-party code
  you can't change
✓ Pipeline-style processing
  (each step wraps the next)
When NOT To Use
✗ Simple cases with 1-2 fixed
  behaviors (just use if/else)
✗ Performance-critical hot paths
  (each layer adds a virtual call)
✗ When you need to REPLACE
  behavior (use Strategy instead)
✗ Deep nesting (>4 layers makes
  debugging stack traces painful)
✗ When subclassing is simpler
  and composition isn't needed
✗ Stateful decorators that
  depend on execution order
Section 20

Stream Decorator Architecture Deep Dive

Every time you write new BufferedStream(new GZipStream(new FileStream(...))), you're building a Decorator chain. Streams are the most widely-used example of the Decorator pattern in all of .NET — and understanding how they work under the hood makes you a better systems programmer. Let's peel back the layers.

How Stream Nesting Works

Think of nested streams like a series of conveyor belts in a factory. Each belt takes items from the one before it, does something to them, and passes them along. The first belt (innermost stream) handles raw materials (bytes on disk), and each belt after that transforms the product a little more.

When you write data, it flows from the outermost stream inward, with each layer transforming the bytes before passing them down. When you read, it's the reverse — raw bytes come up from the innermost stream and each layer transforms them on the way out to your code.

ByteFlowDiagram.txt
WRITE PATH (your code → disk)
═══════════════════════════════════════════════════════════════

  Your Code
      │  Write("Hello World")
      ▼
  BufferedStream        ── collects bytes into 4KB chunks
      │  (waits until buffer is full, then forwards)
      ▼
  GZipStream            ── compresses the chunk
      │  ("Hello World" → 0x1F8B08... compressed bytes)
      ▼
  CryptoStream          ── encrypts the compressed bytes
      │  (0x1F8B08... → 0xA3F2C1... encrypted bytes)
      ▼
  FileStream            ── writes encrypted bytes to disk
      │
      ▼
  [disk]                ── stored as encrypted, compressed data


READ PATH (disk → your code) — the exact reverse
═══════════════════════════════════════════════════════════════

  [disk]
      │  raw encrypted bytes
      ▼
  FileStream            ── reads raw bytes from disk
      │
      ▼
  CryptoStream          ── decrypts bytes
      │
      ▼
  GZipStream            ── decompresses bytes
      │
      ▼
  BufferedStream        ── buffers for efficient small reads
      │
      ▼
  Your Code             ── receives "Hello World"
What Actually Happens: Buffer Flushing

Here's the tricky part that catches people off guard: BufferedStream doesn't forward data immediately. It waits until its internal buffer (default 4KB) is full. That means if you write 100 bytes and then try to read them from another process, they might not be on disk yet. They're sitting in the buffer, waiting.

Calling Flush() on the outermost stream must cascade through every layer. BufferedStream.Flush() pushes its buffer to GZipStream, which flushes its internal compression state to CryptoStream, which flushes its cipher block to FileStream, which calls the OS to write to disk. If any layer doesn't properly forward Flush(), your data can get stuck mid-pipeline.

Streams hold operating system resources — file handles, encryption contexts, compression buffers. When you're done, those resources need to be released. The question is: when you dispose the outermost stream, what happens to all the streams inside it?

By default, disposing a stream also disposes the stream it wraps. This is usually what you want — dispose the outer one and the whole chain cleans up. But sometimes it causes problems, like when two different parts of your code need to use the same inner stream.

Stream Layer Dispose Behavior Risk If Skipped
BufferedStream Flushes remaining buffer, then disposes inner stream Unflushed data is lost — last bytes never reach disk
GZipStream Writes compression footer, then disposes inner stream Corrupted archive — missing GZip trailer makes file unreadable
CryptoStream Writes final cipher block + padding, then disposes inner stream Truncated ciphertext — decryption throws CryptographicException
FileStream Flushes OS buffer, releases file handle File handle leak — other processes can't open the file; OS limit reached after ~10K leaks
ProperDisposal.cs
// CORRECT: nested using statements — disposes outside-in
using var file   = new FileStream("data.bin", FileMode.Create);
using var crypto = new CryptoStream(file, encryptor, CryptoStreamMode.Write);
using var gzip   = new GZipStream(crypto, CompressionLevel.Optimal);
using var buffer = new BufferedStream(gzip, bufferSize: 8192);

await buffer.WriteAsync(data);
// When scope exits:
// buffer.Dispose() → gzip.Dispose() → crypto.Dispose() → file.Dispose()
// Each layer flushes its internal state before disposing the inner stream

Sometimes you don't want the outer stream to dispose the inner one. For example, maybe you need to write a GZip section and then write more uncompressed data to the same file stream. That's what the leaveOpen parameter is for:

LeaveOpen.cs
using var file = new FileStream("mixed.bin", FileMode.Create);

// Write compressed section — but DON'T dispose the file stream when done
using (var gzip = new GZipStream(file, CompressionLevel.Optimal, leaveOpen: true))
{
    await gzip.WriteAsync(compressedSection);
}
// gzip is disposed, but file stream is still alive!

// Write uncompressed footer to the same file
await file.WriteAsync(footer);

// file.Dispose() happens when outer 'using' scope exits

The best way to understand Stream decorators is to build one. Let's create a MonitoringStream that tracks how many bytes are read and written, and measures throughput. This is genuinely useful for debugging slow I/O in production.

The key insight: Stream is an abstract class with many abstract members (Read, Write, CanRead, Seek, Length, etc.) that you must implement yourself. The pattern is straightforward — delegate each one to the inner stream, then add your monitoring logic on top of the methods you care about. We inherit from Stream and wrap another stream inside it — classic Decorator.

MonitoringStream.cs
/// <summary>
/// A stream decorator that monitors read/write throughput.
/// Wraps any Stream and tracks bytes transferred + elapsed time.
/// </summary>
public sealed class MonitoringStream : Stream
{
    private readonly Stream _inner;
    private readonly Stopwatch _readTimer  = new();
    private readonly Stopwatch _writeTimer = new();
    private long _bytesRead;
    private long _bytesWritten;

    public MonitoringStream(Stream inner)
        => _inner = inner ?? throw new ArgumentNullException(nameof(inner));

    // ── Monitoring Properties ──────────────────────────────
    public long BytesRead    => _bytesRead;
    public long BytesWritten => _bytesWritten;

    public double ReadThroughputMBps => _readTimer.Elapsed.TotalSeconds > 0
        ? _bytesRead / 1_048_576.0 / _readTimer.Elapsed.TotalSeconds
        : 0;

    public double WriteThroughputMBps => _writeTimer.Elapsed.TotalSeconds > 0
        ? _bytesWritten / 1_048_576.0 / _writeTimer.Elapsed.TotalSeconds
        : 0;

    // ── Decorated Methods (add monitoring behavior) ────────
    public override int Read(byte[] buffer, int offset, int count)
    {
        _readTimer.Start();
        var bytesActuallyRead = _inner.Read(buffer, offset, count);
        _readTimer.Stop();
        Interlocked.Add(ref _bytesRead, bytesActuallyRead);
        return bytesActuallyRead;
    }

    public override void Write(byte[] buffer, int offset, int count)
    {
        _writeTimer.Start();
        _inner.Write(buffer, offset, count);
        _writeTimer.Stop();
        Interlocked.Add(ref _bytesWritten, count);
    }

    public override async Task<int> ReadAsync(
        byte[] buffer, int offset, int count, CancellationToken ct)
    {
        _readTimer.Start();
        var bytesActuallyRead = await _inner.ReadAsync(buffer, offset, count, ct);
        _readTimer.Stop();
        Interlocked.Add(ref _bytesRead, bytesActuallyRead);
        return bytesActuallyRead;
    }

    public override async Task WriteAsync(
        byte[] buffer, int offset, int count, CancellationToken ct)
    {
        _writeTimer.Start();
        await _inner.WriteAsync(buffer, offset, count, ct);
        _writeTimer.Stop();
        Interlocked.Add(ref _bytesWritten, count);
    }

    // ── Forwarded Methods (pure delegation, no added behavior) ─
    public override bool CanRead  => _inner.CanRead;
    public override bool CanSeek  => _inner.CanSeek;
    public override bool CanWrite => _inner.CanWrite;
    public override long Length   => _inner.Length;
    public override long Position
    {
        get => _inner.Position;
        set => _inner.Position = value;
    }
    public override void Flush()          => _inner.Flush();
    public override long Seek(long o, SeekOrigin origin) => _inner.Seek(o, origin);
    public override void SetLength(long v) => _inner.SetLength(v);

    // ── Dispose the inner stream too ───────────────────────
    protected override void Dispose(bool disposing)
    {
        if (disposing) _inner.Dispose();
        base.Dispose(disposing);
    }

    public override string ToString()
        => $"Read: {BytesRead:N0} bytes ({ReadThroughputMBps:F2} MB/s) | "
         + $"Written: {BytesWritten:N0} bytes ({WriteThroughputMBps:F2} MB/s)";
}

Usage is exactly like any other stream decorator — wrap it around the stream you want to monitor:

Usage.cs
using var file    = new FileStream("large-data.bin", FileMode.Open);
using var monitor = new MonitoringStream(file);
using var gzip    = new GZipStream(monitor, CompressionMode.Decompress);

var buffer = new byte[8192];
int bytesRead;
while ((bytesRead = await gzip.ReadAsync(buffer)) > 0)
{
    // Process decompressed data...
}

// After reading: check how the raw file I/O performed
Console.WriteLine(monitor);
// Output: "Read: 52,428,800 bytes (124.56 MB/s) | Written: 0 bytes (0.00 MB/s)"
Key Insight

Stream is the canonical Decorator in .NET. Every stream wrapper in the framework — BufferedStream, GZipStream, CryptoStream, SslStream — follows the exact same pattern: accept a Stream in the constructor, forward all operations to it, and add specific behavior on top. When someone asks "give me a real-world Decorator example," streams are the answer every .NET developer should know.

Section 21

Mini-Project: Building a Logging Pipeline

Production note: Real .NET apps use Microsoft.Extensions.Logging with ILogger<T> and libraries like Serilog for structured logging. This mini-project deliberately builds logging from scratch to show how Decorator thinking applies — it's a learning exercise, not a replacement for production frameworks.

We'll build the same logging system three times. Each attempt gets closer to a production-quality design. Watch how each version fixes the problems of the one before it — that's how you internalize the Decorator pattern.

Attempt 1: The Monolithic Logger

The first instinct is to put everything in one class. Timestamps, formatting, filtering, output — all crammed together. It works, but it's a maintenance nightmare waiting to happen.

SuperLogger.cs
// Custom enum for our logging mini-project (not the built-in Microsoft.Extensions.Logging one)
public enum LogLevel { Debug, Info, Warning, Error }

public class SuperLogger
{
    private readonly string _filePath;
    private readonly LogLevel _minLevel;
    private readonly bool _useJson;
    private readonly bool _addTimestamp;
    private readonly bool _writeToConsole;
    private readonly bool _writeToFile;

    public SuperLogger(
        string filePath,
        LogLevel minLevel = LogLevel.Info,
        bool useJson = false,
        bool addTimestamp = true,
        bool writeToConsole = true,
        bool writeToFile = false)
    {
        _filePath       = filePath;
        _minLevel       = minLevel;
        _useJson        = useJson;
        _addTimestamp   = addTimestamp;
        _writeToConsole = writeToConsole;
        _writeToFile    = writeToFile;
    }

    public void Log(LogLevel level, string message)
    {
        // Filtering — mixed in with everything else
        if (level < _minLevel) return;

        // Formatting — depends on boolean flag
        string formatted;
        if (_useJson)
        {
            formatted = JsonSerializer.Serialize(new
            {
                level = level.ToString(),
                message,
                timestamp = _addTimestamp ? DateTime.UtcNow.ToString("o") : null
            });
        }
        else
        {
            formatted = _addTimestamp
                ? $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}] [{level}] {message}"
                : $"[{level}] {message}";
        }

        // Output — more boolean flags
        if (_writeToConsole)
        {
            var originalColor = Console.ForegroundColor;
            Console.ForegroundColor = level switch
            {
                LogLevel.Error => ConsoleColor.Red,
                LogLevel.Warning => ConsoleColor.Yellow,
                _ => ConsoleColor.White
            };
            Console.WriteLine(formatted);
            Console.ForegroundColor = originalColor;
        }

        if (_writeToFile)
        {
            File.AppendAllText(_filePath, formatted + Environment.NewLine);
        }
    }
}
5 Critical Problems
  • SRP violation — One class handles filtering, formatting, timestamping, console output, AND file output. Changing any one concern risks breaking the others.
  • Untestable — How do you unit-test just the JSON formatting without also dealing with file I/O and console colors? You can't isolate anything.
  • Can't swap concerns — Want to add Slack notifications? You'd have to add yet another boolean flag and more code to the same growing class.
  • Rigid configuration — The constructor already has 6 parameters. Adding "write to database" or "encrypt sensitive fields" means even more parameters and if branches.
  • No extensibility — A new developer can't add a feature without modifying this class. That violates the Open/Closed Principle.

Now we split each concern into its own decorator. Each class does exactly one thing and wraps the next logger in the chain. This is the Decorator pattern in action — but we'll wire them manually first to see the pain point.

DecoratorAttempt.cs
// Step 1: Define what "a logger" looks like
public interface ILogWriter
{
    void Write(LogEntry entry);
}

public record LogEntry(LogLevel Level, string Message, DateTime? Timestamp = null);

// Step 2: The real output destination (innermost layer)
public sealed class ConsoleLogWriter : ILogWriter
{
    public void Write(LogEntry entry)
    {
        var color = entry.Level switch
        {
            LogLevel.Error   => ConsoleColor.Red,
            LogLevel.Warning => ConsoleColor.Yellow,
            _                => ConsoleColor.White
        };
        var prev = Console.ForegroundColor;
        Console.ForegroundColor = color;
        Console.WriteLine(entry.Message);
        Console.ForegroundColor = prev;
    }
}

// Step 3: Decorators — each one does ONE thing, then forwards
public sealed class TimestampDecorator : ILogWriter
{
    private readonly ILogWriter _inner;
    public TimestampDecorator(ILogWriter inner) => _inner = inner;

    public void Write(LogEntry entry)
    {
        // Add timestamp to the message, then forward
        var stamped = entry with
        {
            Timestamp = DateTime.UtcNow,
            Message   = $"[{DateTime.UtcNow:HH:mm:ss.fff}] {entry.Message}"
        };
        _inner.Write(stamped);
    }
}

public sealed class JsonFormatDecorator : ILogWriter
{
    private readonly ILogWriter _inner;
    public JsonFormatDecorator(ILogWriter inner) => _inner = inner;

    public void Write(LogEntry entry)
    {
        // Convert the entry to JSON format, then forward
        var json = JsonSerializer.Serialize(new
        {
            level     = entry.Level.ToString(),
            message   = entry.Message,
            timestamp = entry.Timestamp?.ToString("o")
        });
        _inner.Write(entry with { Message = json });
    }
}

public sealed class FilterDecorator : ILogWriter
{
    private readonly ILogWriter _inner;
    private readonly LogLevel _minLevel;

    public FilterDecorator(ILogWriter inner, LogLevel minLevel)
    {
        _inner    = inner;
        _minLevel = minLevel;
    }

    public void Write(LogEntry entry)
    {
        // Only forward entries that meet the minimum level
        if (entry.Level >= _minLevel)
            _inner.Write(entry);
        // Below minimum? Silently dropped — no forwarding
    }
}

Now wiring it together. Each decorator wraps the next, building a pipeline from outside in:

ManualWiring.cs
// Manual wiring — read inside-out:
// FilterDecorator → TimestampDecorator → JsonFormatDecorator → ConsoleLogWriter
ILogWriter logger = new FilterDecorator(
    new TimestampDecorator(
        new JsonFormatDecorator(
            new ConsoleLogWriter()
        )
    ),
    minLevel: LogLevel.Warning
);

// Usage — caller doesn't know about the chain
logger.Write(new LogEntry(LogLevel.Debug, "This gets filtered out"));
logger.Write(new LogEntry(LogLevel.Error, "This gets filtered → stamped → JSON → console"));
Remaining Issues
  • Manual wiring is fragile — Changing the pipeline means finding and editing every place that builds the chain. In a real app, that could be dozens of files.
  • No DI integration — You can't inject ILogWriter into your services because the container doesn't know about the decorator chain.
  • Hard to reconfigure — Want JSON in production but plain text in development? You'd need conditional logic everywhere the chain is built.

The final version uses dependency injection to manage the decorator chain. ScrutorA NuGet library that extends Microsoft.Extensions.DependencyInjection with decorator support. Its Decorate<TInterface, TDecorator>() method automatically wraps the existing registration. Each Decorate call wraps the previous one, building the chain for you. handles all the wiring — you just declare which decorators you want and in what order.

ILogWriter.cs
/// <summary>
/// The component interface — every logger (real or decorator) implements this.
/// </summary>
public interface ILogWriter
{
    void Write(LogEntry entry);
    Task WriteAsync(LogEntry entry, CancellationToken ct = default);
}

/// <summary>
/// Immutable log entry — decorators create modified copies using 'with'.
/// </summary>
public record LogEntry(
    LogLevel Level,
    string Message,
    DateTime? Timestamp = null,
    Dictionary<string, object>? Properties = null)
{
    // Auto-set timestamp if not provided
    public DateTime ResolvedTimestamp => Timestamp ?? DateTime.UtcNow;
}

/// <summary>
/// Base decorator — forwards everything by default.
/// Concrete decorators override only the methods they enhance.
/// </summary>
public abstract class LogWriterDecorator : ILogWriter
{
    protected readonly ILogWriter Inner;
    protected LogWriterDecorator(ILogWriter inner)
        => Inner = inner ?? throw new ArgumentNullException(nameof(inner));

    public virtual void Write(LogEntry entry) => Inner.Write(entry);
    public virtual Task WriteAsync(LogEntry entry, CancellationToken ct = default)
        => Inner.WriteAsync(entry, ct);
}
TimestampDecorator.cs
/// <summary>
/// Adds a UTC timestamp to every log entry.
/// Uses TimeProvider for testability (no DateTime.UtcNow coupling).
/// </summary>
public sealed class TimestampDecorator : LogWriterDecorator
{
    private readonly TimeProvider _clock;

    public TimestampDecorator(ILogWriter inner, TimeProvider clock)
        : base(inner) => _clock = clock;

    public override void Write(LogEntry entry)
    {
        var now = _clock.GetUtcNow().DateTime;
        var stamped = entry with
        {
            Timestamp = now,
            Message   = $"[{now:yyyy-MM-dd HH:mm:ss.fff}] {entry.Message}"
        };
        Inner.Write(stamped);
    }

    public override async Task WriteAsync(LogEntry entry, CancellationToken ct)
    {
        var now = _clock.GetUtcNow().DateTime;
        var stamped = entry with
        {
            Timestamp = now,
            Message   = $"[{now:yyyy-MM-dd HH:mm:ss.fff}] {entry.Message}"
        };
        await Inner.WriteAsync(stamped, ct);
    }
}

/// <summary>
/// Drops log entries below the configured minimum level.
/// </summary>
public sealed class LevelFilterDecorator : LogWriterDecorator
{
    private readonly LogLevel _minLevel;

    public LevelFilterDecorator(ILogWriter inner, IOptions<LoggingOptions> opts)
        : base(inner) => _minLevel = opts.Value.MinLevel;

    public override void Write(LogEntry entry)
    {
        if (entry.Level >= _minLevel)
            Inner.Write(entry);
    }

    public override Task WriteAsync(LogEntry entry, CancellationToken ct)
        => entry.Level >= _minLevel
            ? Inner.WriteAsync(entry, ct)
            : Task.CompletedTask;
}
Program.cs
var builder = WebApplication.CreateBuilder(args);

// Configuration
builder.Services.Configure<LoggingOptions>(
    builder.Configuration.GetSection("Logging"));

// Time provider (swap for FakeTimeProvider in tests)
builder.Services.AddSingleton(TimeProvider.System);

// Step 1: Register the real writer (innermost layer)
builder.Services.AddScoped<ILogWriter, ConsoleLogWriter>();

// Step 2: Stack decorators using Scrutor
// Order matters — last Decorate() becomes the outermost wrapper
builder.Services.Decorate<ILogWriter, TimestampDecorator>();
builder.Services.Decorate<ILogWriter, LevelFilterDecorator>();

// What DI builds when you inject ILogWriter:
//   LevelFilterDecorator
//     → TimestampDecorator
//       → ConsoleLogWriter
//
// A log entry flows:
//   Filter checks level → Timestamp adds time → Console prints

var app = builder.Build();

// Any service can now inject ILogWriter — they get the full chain
app.MapPost("/orders", async (Order order, ILogWriter log) =>
{
    log.Write(new LogEntry(LogLevel.Info, $"Order received: {order.Id}"));
    // ... process order
    return Results.Ok();
});
TimestampDecoratorTests.cs
public class TimestampDecoratorTests
{
    private readonly Mock<ILogWriter> _mockInner = new();
    private readonly FakeTimeProvider _clock = new();
    private readonly TimestampDecorator _sut;

    public TimestampDecoratorTests()
    {
        _clock.SetUtcNow(new DateTimeOffset(2026, 3, 8, 14, 30, 0, TimeSpan.Zero));
        _sut = new TimestampDecorator(_mockInner.Object, _clock);
    }

    [Fact]
    public void Write_AddsTimestampToMessage()
    {
        // Arrange
        var entry = new LogEntry(LogLevel.Info, "Order placed");

        // Act
        _sut.Write(entry);

        // Assert — the inner writer received a modified entry
        _mockInner.Verify(w => w.Write(It.Is<LogEntry>(e =>
            e.Message.Contains("2026-03-08 14:30:00") &&
            e.Message.Contains("Order placed") &&
            e.Timestamp == new DateTime(2026, 3, 8, 14, 30, 0)
        )), Times.Once);
    }

    [Fact]
    public void Write_AlwaysForwardsToInner()
    {
        // Decorator MUST forward — this test catches the #1 mistake
        _sut.Write(new LogEntry(LogLevel.Debug, "test"));

        _mockInner.Verify(w => w.Write(It.IsAny<LogEntry>()), Times.Once);
    }

    [Fact]
    public void Write_DoesNotModifyOriginalEntry()
    {
        // Immutability check — 'with' creates a copy, original is untouched
        var original = new LogEntry(LogLevel.Info, "original message");

        _sut.Write(original);

        Assert.Equal("original message", original.Message);
        Assert.Null(original.Timestamp);  // not set — decorators add it via 'with'
    }
}

public class LevelFilterDecoratorTests
{
    [Theory]
    [InlineData(LogLevel.Debug, LogLevel.Info,  false)] // below min → filtered
    [InlineData(LogLevel.Info,  LogLevel.Info,  true)]  // at min → forwarded
    [InlineData(LogLevel.Error, LogLevel.Info,  true)]  // above min → forwarded
    [InlineData(LogLevel.Error, LogLevel.Error, true)]  // at min → forwarded
    public void Filters_Based_On_Configured_MinLevel(
        LogLevel entryLevel, LogLevel minLevel, bool shouldForward)
    {
        var mockInner = new Mock<ILogWriter>();
        var opts = Options.Create(new LoggingOptions { MinLevel = minLevel });
        var sut = new LevelFilterDecorator(mockInner.Object, opts);

        sut.Write(new LogEntry(entryLevel, "test"));

        mockInner.Verify(
            w => w.Write(It.IsAny<LogEntry>()),
            shouldForward ? Times.Once() : Times.Never());
    }
}

Why This Is Production-Ready

Single Responsibility

Each decorator does exactly one thing. TimestampDecorator adds timestamps. LevelFilterDecorator filters. ConsoleLogWriter prints. Changing one never affects the others.

DI Managed

Scrutor handles all wiring automatically. Want to swap JSON formatting for plain text? Change one line in Program.cs. No other file changes needed.

Independently Testable

Mock the inner ILogWriter, pass it to one decorator, and test that decorator in complete isolation. No console, no files, no other decorators involved.

Open for Extension

Need Slack alerts? Create SlackNotificationDecorator and add one Decorate<>() call. Zero modifications to existing code — pure Open/Closed Principle.

Section 22

Migration Guide: Monolithic Service → Decorator Chain

You've inherited a monolithic service that handles validation, logging, and caching all in one class. It works, but every change is risky because touching one concern might break another. Here's how to safely extract each concern into its own decorator, step by step, with zero downtime.

Step 1: Extract Interface

Before you can decorate anything, you need an interface. The monolithic class becomes the first (and currently only) implementation. No behavior changes — just adding a contract on top of what already exists.

Step1_ExtractInterface.cs
// BEFORE: monolithic class — validation, logging, caching all baked in
public class OrderService
{
    private readonly IMemoryCache _cache;
    private readonly ILogger<OrderService> _logger;

    public async Task<Order> GetOrderAsync(int id)
    {
        _logger.LogInformation("Getting order {Id}", id);  // logging concern

        if (_cache.TryGetValue($"order-{id}", out Order? cached))  // caching concern
            return cached!;

        // validation concern
        if (id <= 0) throw new ArgumentException("Invalid order ID");

        var order = await _db.Orders.FindAsync(id);

        _cache.Set($"order-{id}", order, TimeSpan.FromMinutes(5));
        _logger.LogInformation("Order {Id} retrieved", id);
        return order!;
    }

    public async Task<Order> PlaceOrderAsync(OrderRequest request)
    {
        _logger.LogInformation("Placing order for {Customer}", request.CustomerId);

        // validation — mixed in with business logic
        if (string.IsNullOrWhiteSpace(request.CustomerId))
            throw new ValidationException("Customer ID required");
        if (request.Items.Count == 0)
            throw new ValidationException("Order must have items");

        var order = new Order { /* ... */ };
        await _db.Orders.AddAsync(order);
        await _db.SaveChangesAsync();

        _cache.Remove($"orders-{request.CustomerId}");
        _logger.LogInformation("Order {Id} placed", order.Id);
        return order;
    }
}

// AFTER: extract the public methods into an interface
// (In Visual Studio: right-click class → Quick Actions → Extract Interface)
public interface IOrderService
{
    Task<Order> GetOrderAsync(int id);
    Task<Order> PlaceOrderAsync(OrderRequest request);
}

// The original class now implements the interface — no other changes
public class OrderService : IOrderService
{
    // Exact same code as before — nothing moved, nothing deleted
    // ...
}

Risk: Zero. Adding an interface is backward-compatible. All existing code still calls the concrete class. You're just adding a contract that decorators can target later.

The base decorator implements the interface and forwards every call to an inner instance. It does nothing on its own — it's just a transparent pass-through. Concrete decorators will override specific methods to add behavior.

Step2_BaseDecorator.cs
/// <summary>
/// Base decorator — pure forwarding, no added behavior.
/// Concrete decorators inherit this and override what they need.
/// Any method NOT overridden passes straight through to the inner service.
/// </summary>
public abstract class OrderServiceDecorator : IOrderService
{
    protected readonly IOrderService Inner;

    protected OrderServiceDecorator(IOrderService inner)
        => Inner = inner ?? throw new ArgumentNullException(nameof(inner));

    public virtual Task<Order> GetOrderAsync(int id)
        => Inner.GetOrderAsync(id);

    public virtual Task<Order> PlaceOrderAsync(OrderRequest request)
        => Inner.PlaceOrderAsync(request);
}

// Quick test: plug it in and verify NOTHING changes
// If your app behaves identically with and without the base decorator,
// you know the forwarding is correct.

Risk: Low. The base decorator is a transparent wrapper. All behavior still lives in the original OrderService. You can wire it into DI temporarily to verify nothing breaks: services.Decorate<IOrderService, TransparentDecorator>() should produce identical behavior.

Now extract each concern from the monolith into its own decorator. One at a time — don't try to do all three in one pull request. Each decorator overrides only the methods where it adds behavior.

Step3_SplitDecorators.cs
// ── Decorator 1: Validation ────────────────────────────────
public sealed class ValidationDecorator : OrderServiceDecorator
{
    public ValidationDecorator(IOrderService inner) : base(inner) { }

    public override Task<Order> GetOrderAsync(int id)
    {
        if (id <= 0)
            throw new ArgumentException("Order ID must be positive", nameof(id));
        return Inner.GetOrderAsync(id);  // forward to next layer
    }

    public override Task<Order> PlaceOrderAsync(OrderRequest request)
    {
        if (string.IsNullOrWhiteSpace(request.CustomerId))
            throw new ValidationException("Customer ID is required");
        if (request.Items.Count == 0)
            throw new ValidationException("Order must have at least one item");
        return Inner.PlaceOrderAsync(request);  // forward to next layer
    }
}

// ── Decorator 2: Logging ───────────────────────────────────
public sealed class LoggingDecorator : OrderServiceDecorator
{
    private readonly ILogger<LoggingDecorator> _logger;

    public LoggingDecorator(IOrderService inner, ILogger<LoggingDecorator> logger)
        : base(inner) => _logger = logger;

    public override async Task<Order> GetOrderAsync(int id)
    {
        _logger.LogInformation("Getting order {OrderId}", id);
        var sw = Stopwatch.StartNew();

        var order = await Inner.GetOrderAsync(id);

        _logger.LogInformation("Got order {OrderId} in {Elapsed}ms",
            id, sw.ElapsedMilliseconds);
        return order;
    }

    public override async Task<Order> PlaceOrderAsync(OrderRequest request)
    {
        _logger.LogInformation("Placing order for customer {CustomerId}",
            request.CustomerId);

        var order = await Inner.PlaceOrderAsync(request);

        _logger.LogInformation("Placed order {OrderId} for customer {CustomerId}",
            order.Id, request.CustomerId);
        return order;
    }
}

// ── Decorator 3: Caching ───────────────────────────────────
public sealed class CachingDecorator : OrderServiceDecorator
{
    private readonly IMemoryCache _cache;

    public CachingDecorator(IOrderService inner, IMemoryCache cache)
        : base(inner) => _cache = cache;

    public override async Task<Order> GetOrderAsync(int id)
    {
        var key = $"order-{id}";

        if (_cache.TryGetValue(key, out Order? cached))
            return cached!;  // cache hit — skip inner entirely

        var order = await Inner.GetOrderAsync(id);

        _cache.Set(key, order, TimeSpan.FromMinutes(5));
        return order;
    }

    public override async Task<Order> PlaceOrderAsync(OrderRequest request)
    {
        var order = await Inner.PlaceOrderAsync(request);

        // Invalidate customer's order list cache after placing a new order
        _cache.Remove($"orders-{request.CustomerId}");
        return order;
    }
}

Risk: Medium. You're moving logic out of the original class and into decorators. Write integration tests that compare the old monolith's behavior to the new decorator chain's behavior. The output should be identical for every input. If you can run both side-by-side for a while (feature flag), even better.

Finally, register everything in the DI container. Scrutor's Decorate<>() method handles the wrapping automatically. The last Decorate call becomes the outermost layer — the first thing that sees each request.

Step4_DIRegistration.cs
var builder = WebApplication.CreateBuilder(args);

// Infrastructure
builder.Services.AddMemoryCache();

// Step 1: Register the CLEAN OrderService (all cross-cutting code removed)
builder.Services.AddScoped<IOrderService, OrderService>();

// Step 2: Stack decorators — order matters!
// First Decorate() = innermost wrapper, last = outermost
builder.Services.Decorate<IOrderService, ValidationDecorator>();
builder.Services.Decorate<IOrderService, CachingDecorator>();
builder.Services.Decorate<IOrderService, LoggingDecorator>();

// What DI builds when you inject IOrderService:
//
//   LoggingDecorator           (outermost — sees every call first)
//     → CachingDecorator       (short-circuits on cache hit)
//       → ValidationDecorator  (validates before real work)
//         → OrderService       (actual business logic — CLEAN now)
//
// Why this order?
// - Logging is outermost so it captures timing for the ENTIRE chain
// - Caching is before validation so cache hits skip validation cost
// - Validation is closest to the real service to catch bad data last-minute

var app = builder.Build();

// Controllers/endpoints just inject IOrderService — no idea about decorators
app.MapGet("/orders/{id}", async (int id, IOrderService orders)
    => Results.Ok(await orders.GetOrderAsync(id)));

// The old monolithic OrderService can now be deleted:
// ✓ Validation logic → ValidationDecorator
// ✓ Logging logic → LoggingDecorator
// ✓ Caching logic → CachingDecorator
// ✓ OrderService now contains ONLY business logic
Migration Complete

Each concern is now isolated in its own class, independently testable, and easily swappable. Want to replace in-memory caching with Redis? Swap CachingDecorator for RedisCachingDecorator — one line change in Program.cs. Want to disable logging in load tests? Remove the Decorate line. The original service is now clean business logic with zero cross-cutting concerns baked in.

Section 23

Code Review Checklist

Use this checklist during code reviews to catch Decorator pattern issues before they reach production. Each check targets a specific mistake that causes real bugs in real systems.

# Check Why It Matters Red Flag
1 Decoration is interface-based Decorating concrete classes creates tight coupling — you can't swap implementations or test with mocks class LoggingDecorator : OrderService (inheriting concrete class instead of implementing IOrderService)
2 Base decorator class exists Without a base decorator, every concrete decorator must manually forward ALL interface methods — easy to miss one Multiple decorators each implementing the full interface independently with copy-pasted forwarding code
3 All interface methods are forwarded A decorator that doesn't forward a method silently breaks that method's behavior for the entire chain NotImplementedException or empty methods in a decorator that should be forwarding
4 Proper IDisposable implementation If the inner component is disposable, the decorator must dispose it — otherwise you leak resources (file handles, DB connections) Decorator that wraps a disposable service but doesn't implement IDisposable itself
5 Async methods are consistently async Mixing .Result or .Wait() in an async decorator causes deadlocks in ASP.NET and UI frameworks Inner.DoAsync().Result inside a decorator instead of await Inner.DoAsync()
6 No accidental double-wrapping Calling Decorate<>() twice with the same decorator type wraps it twice — logging happens twice, caching caches the cache, etc. Two identical services.Decorate<IService, LoggingDecorator>() lines in DI registration
7 DI registration order is correct Scrutor's Decorate<>() order determines the chain. Wrong order means caching happens before validation, or logging misses timing data No comment explaining why decorators are registered in that specific order
8 Decorator ordering is documented Future developers will add decorators in the wrong position if the intended order isn't explained Three or more Decorate<>() calls with no comments about why that order was chosen
9 Each decorator has a single responsibility A decorator that handles logging AND caching is just a mini-monolith — you've defeated the purpose of the pattern A decorator class with more than one cross-cutting concern (e.g., LoggingAndCachingDecorator)
10 Unit tests exist for each decorator Decorators are deceptively simple — but forwarding bugs, null handling, and async issues are easy to miss without tests Integration tests for the full chain but no isolated unit tests for individual decorators
11 No concrete class decoration in DI Decorate<>() only works on interface registrations. Trying to decorate a concrete type silently fails or throws at runtime services.AddScoped<OrderService>() followed by services.Decorate<OrderService, LoggingDecorator>()
12 Performance impact is assessed Each decorator layer adds a virtual method call + potential allocation. Five decorators on a hot path (called 10K+/sec) can measurably impact latency Decorator chain on a method called inside a tight loop without benchmarking
Automate it with Roslyn Analyzers. Many of these checks can be enforced automatically at compile time, so they never reach code review in the first place:
  • CA1063 — Implement IDisposable correctly (catches check #4: decorators that forget to dispose the inner component)
  • CA2000 — Dispose objects before losing scope (catches decorator chains where a stream or connection is created but never disposed)
  • CA1816 — Call GC.SuppressFinalize correctly (ensures your Dispose pattern follows the framework guidelines)
  • IDE0060 — Remove unused parameter (catches constructors that accept the inner component but never use it — a sign of unforwarded calls)
  • Custom analyzer — Detect unforwarded interface members. Write a Roslyn analyzer that compares each decorator's overridden methods against the full interface and warns if any method body doesn't call Inner.MethodName(). This catches the most common Decorator bug: forgetting to forward.