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.
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 World
What it means
In 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
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.
Participant
Role
Responsibility
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.
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
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.
Era
Key Feature
Decorator Impact
.NET 1.0 (2002)
Stream class hierarchy
Established Decorator as a core .NET pattern
.NET 2.0 (2005)
Generics
One decorator class works for any type — no more per-type wrappers
.NET 3.5 (2007)
LINQ
Functional decoration via method chaining
ASP.NET Core (2016+)
Middleware pipeline
Decorator becomes the architecture of the entire web framework
.NET 8+ (2023+)
Scrutor & Keyed Services
DI-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..
.NET 3.5 (2007) — LINQ as Functional Decoration
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 (2016+) — Middleware IS Decorator
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).
.NET 8+ (2023+) — Scrutor & Keyed Services for DI Decoration
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.
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.
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);
ASP.NET Core Middleware (Use / Map / Run)
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();
ILogger Decorators (Custom Logging via DI)
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
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
Fixes behavior at compile time — you get exactly what the subclass defines
Combining features means class explosion — LoggingCachingRetryService
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
Principle
Relation
Explanation
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
Bug 1: Decorator Doesn't Forward All Interface Members
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().
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.
Bug 2: Double-Wrapping Same Decorator
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.
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.
Bug 3: Order-Dependent Decorators Applied Wrong
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
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.
Bug 4: Dispose Not Propagated Through Decorator Chain
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.
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.
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.
Bug 5: Async Decorator Blocks Synchronously
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
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.
Bug 6: Decorator Breaks Equality / GetHashCode
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.
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
1. Decorating concrete classes instead of interfaces
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 — 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.
2. Creating a "God Decorator" that does everything
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
// ❌ 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.
3. Not providing a base decorator class (forwarding boilerplate everywhere)
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.
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
}
4. Forgetting to forward constructor dependencies
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 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.
5. Using Decorator when simple inheritance would work
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.
❌ 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.
6. Infinite decorator chains (A wraps B wraps A)
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 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.
7. Breaking the Liskov Substitution Principle in decorators
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 — 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.
8. Not testing decorators in isolation
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.
✅ 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.
Test the Component Without Decorators
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>()
}));
}
}
Test Each Decorator in Isolation
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);
}
}
Test the Full Decorator Chain
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
}
}
Test Decorator Registration in DI
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 Layers
Object Allocations
Call 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.
Virtual Dispatch Cost
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.
GC Pressure from Wrapper Objects
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)
Q1: What is the Decorator pattern and what problem does it solve?
Easy
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.
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."
Q2: How is Decorator different from inheritance?
Easy
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:
Combinability: Inheritance needs a new class for every combination (2N explosion). Decorator needs N classes total — compose any subset.
Runtime flexibility: With Decorator, you can add/remove behavior based on configuration, feature flags, or tenant settings. Inheritance is static.
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."
Q3: Can you give a real-world analogy for Decorator?
Easy
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."
Q4: What are the key components of the Decorator pattern?
Easy
Think First How many roles are in the Decorator pattern? What does each one do?
Four roles:
Component (interface) — the contract both the real object and decorators implement. Example: IOrderService
Concrete Component — the real object with actual business logic. Example: OrderService
Base Decorator (abstract) — implements the interface and forwards all calls to the inner component. Optional but highly recommended to avoid forwarding boilerplate
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."
Q5: What problem does Decorator solve that simple wrapper classes don't?
Easy
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)
Q6: How does .NET's Stream class use the Decorator pattern?
Medium
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.
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."
Q7: How would you implement Decorator with ASP.NET Core's DI container?
Medium
Think First The built-in Microsoft DI container doesn't have a Decorate method. How would you wire a decorator without Scrutor?
Two approaches:
Manual factory (built-in DI): Register a factory lambda that resolves the inner service and wraps it.
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."
Q8: When should you NOT use the Decorator pattern?
Medium
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."
Q9: How does Decorator relate to ASP.NET Core middleware?
Medium
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."
Q10: What's the difference between Decorator and Proxy?
Medium
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).
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."
Q11: How do you handle thread safety in decorators?
Medium
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."
Q12: Why is a base decorator class important?
Medium
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)
Q13: How would you implement an async retry decorator?
Hard
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."
Q14: How does Scrutor implement decoration internally?
Hard
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:
Find the existing ServiceDescriptor for IOrderService in the service collection
Remove that registration
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
Preserve the lifetime — if OrderService was registered as Scoped, the decorator is also Scoped
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."
Q15: How do you control decorator ordering in DI?
Hard
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.
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."
Q16: What's the performance overhead of decorator chains?
Hard
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."
Q17: Interceptors vs Decorators — what's the difference?
Hard
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.
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."
Q18: How do you use Decorator with generics?
Hard
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."
Q19: How do you handle cross-cutting concerns with Decorator vs AOP?
Hard
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."
Q20: How do you test decorators effectively?
Hard
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:
Unit test each decorator in isolation — mock the inner service, verify the decorator adds its specific behavior. This is the most important layer.
Integration test the full chain — stack all decorators on a real (or in-memory) service, verify end-to-end behavior.
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."
Q21: What's the memory impact of deep decorator chains?
Hard
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 — 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."
Q22: How do you handle disposal in a decorator chain?
Hard
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."
Q23: How would you use Decorator in a microservices architecture?
Hard
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().
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."
Q24: Can you implement Decorator at compile time with source generators?
Hard
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."
Q25: How does Decorator integrate with OpenTelemetry for observability?
Hard
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."
Q26: What are production-ready decorator patterns you've used?
Hard
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."
Q27: Middleware vs Decorator vs Pipeline — when do you use each?
Hard
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)
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."
Q28: How do you debug issues in a deep decorator chain?
Hard
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:
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.
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.
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.
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."
Q29: Design a decorator-based solution for a complex real-world scenario
Hard
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:
TracingDecorator (outermost) — creates OpenTelemetry span, captures full call duration including retries
MetricsDecorator — records latency histogram, success/failure counters
LoggingDecorator — logs entry/exit, method args, correlation ID
RateLimitingDecorator — per-tenant rate limiting, throws 429 if exceeded
CircuitBreakerDecorator — opens after 5 failures, half-open after 30s
RetryDecorator — 3 retries with exponential backoff + jitter
PaymentGatewayHttpClient (innermost, concrete) — makes the actual HTTP call
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.
Hints
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
Solution
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.
Hints
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
Solution
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.
Hints
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
Solution
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>();
Hints
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?
Solution
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.
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.
The Dispose Chain Problem
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
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
Building Your Own Stream Decorator
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.
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.
Attempt 2: Separate Decorators, Manual Wiring
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.
Attempt 3: Production-Ready with DI
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.
Step 2: Create Base Decorator
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.
Step 3: Split Into Decorators
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.
Step 4: Wire via DI
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.
Section 24
Related Topics / What to Study Next
Proxy Pattern
Controls access to an object. Decorator adds behavior; Proxy adds access control. They look almost identical in structure — same "wraps an interface, forwards calls" shape — but the intent is completely different. Choose Decorator when you're enhancing, Proxy when you're guarding.
Chain of Responsibility
Both patterns chain objects together, but they differ in one crucial way: Chain of Responsibility can stop propagation (a handler decides "I'll handle this, don't pass it on"), while Decorator always forwards to the inner object. Use CoR for request routing, Decorator for behavior layering.
Composite Pattern
Both use recursive composition — an object contains another object of the same type. Composite treats a group of objects uniformly (a folder contains files and other folders). Decorator enhances a single object. They're often used together: decorate a composite, or compose decorated objects.
Instead of a class for every combination, a senior creates one class per format. Each format is a decorator that wraps any ITextComponent. Want Bold + Italic? Wrap Italic around Bold. Want all four? Stack four wrappers. The math changes from exponential to linear: N formats = N classes, not 2N. That's the difference between a system that scales and one that collapses under its own weight.
One class per format — O(N) instead of O(2^N)
// 4 formats = 4 classes (not 16)
ITextComponent text = new PlainText();
text = new BoldDecorator(text); // +bold
text = new ItalicDecorator(text); // +italic
text = new UnderlineDecorator(text); // +underline
text = new ColorDecorator(text); // +color
// Any subset, any order — just pick the wrappers you want
ITextComponent minimal = new ItalicDecorator(new PlainText()); // just italic
Senior Solution: Adding New Formats
With the Decorator approach, adding a new format means creating exactly one new class. No changes to any existing class — not PlainText, not BoldDecorator, not ItalicDecorator, nothing. The new decorator can wrap anything that implements ITextComponent — including all the existing decorators. This is 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 is the textbook example: you extend behavior by adding new wrapper classes, never by modifying existing ones. in its purest form.
Adding strikethrough — ONE new class, zero changes
// New format: just one class
public class StrikethroughDecorator : ITextComponent
{
private readonly ITextComponent _inner;
public StrikethroughDecorator(ITextComponent inner) => _inner = inner;
public string Format(string text) => $"<s>{_inner.Format(text)}</s>";
}
// Instantly works with every existing format
ITextComponent fancy = new StrikethroughDecorator(
new BoldDecorator(
new ColorDecorator(new PlainText(), "purple")));
Senior Solution: Runtime Flexibility
The junior's approach locks in the formatting choice at compile time — you pick a class and that's what you get. The senior's approach defers the choice to runtime. You can build the wrapper chain based on user preferences, feature flags, configuration files, database settings — whatever you want. The decision of "which formats to apply" is made when the program runs, not when you write the code. Different users can get completely different decorator chains, all using the same small set of classes.
Runtime composition — driven by user settings
// Build decorator chain from user preferences at runtime
ITextComponent formatter = new PlainText();
if (userSettings.WantsBold)
formatter = new BoldDecorator(formatter);
if (userSettings.WantsItalic)
formatter = new ItalicDecorator(formatter);
if (userSettings.WantsColor)
formatter = new ColorDecorator(formatter, userSettings.PreferredColor);
// The formatter object now does exactly what this user wants
// Different users get different decorator chains — same classes, different composition