Decorator Pattern: Adding Caching, Logging and Retry Without Touching the Code
Decorator adds behavior to an object at runtime by implementing the same interface and delegating to the object inside; the order of decorators is a design decision that determines what they measure and what they protect. Adding cache, logging and retries to Bookstore's catalog, the Stream family and ownership with leaveOpen, manual DI registration and Scrutor's Decorate, DelegatingHandler and how default interface methods silently bypass a decorator.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
Bookstore's product page reads the book from the database every time it opens. The team wants two things: not to hit the database again and again for the same book (a cache), and to log catalog calls. Without touching SqlBookCatalog, two classes are written that implement the same interface and delegate the work to an IBookCatalog inside them. Then they're stacked in two different orders:
SqlBookCatalog db = new();
IBookCatalog loggingOutside = new LoggingBookCatalog(new CachingBookCatalog(db));
IBookCatalog loggingInside = new CachingBookCatalog(new LoggingBookCatalog(db));
foreach ((string name, IBookCatalog catalog) in new[] { ("log -> cache -> db", loggingOutside), ("cache -> log -> db", loggingInside) })
{
Console.WriteLine(name);
for (int i = 0; i < 3; i++)
await catalog.GetByIsbnAsync("978-0141439518");
}
Console.WriteLine($"Database queries: {db.Queries}");
public sealed record Book(string Isbn, string Title, decimal Price);
// Component: the only contract the client knows
public interface IBookCatalog
{
Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default);
}
// Concrete component: the real work (simulating the database here)
public sealed class SqlBookCatalog : IBookCatalog
{
public int Queries { get; private set; }
public async Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default)
{
Queries++;
await Task.Delay(20, cancellationToken);
return isbn == "978-0141439518" ? new Book(isbn, "Emma", 9.50m) : null;
}
}
// Decorator: same interface, an IBookCatalog inside, behavior in between
public sealed class CachingBookCatalog(IBookCatalog inner) : IBookCatalog
{
private readonly Dictionary<string, Book?> _cache = [];
public async Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(isbn, out Book? cached))
return cached;
Book? book = await inner.GetByIsbnAsync(isbn, cancellationToken);
_cache[isbn] = book;
return book;
}
}
public sealed class LoggingBookCatalog(IBookCatalog inner) : IBookCatalog
{
public async Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default)
{
Console.WriteLine($" [log] GetByIsbn {isbn}");
return await inner.GetByIsbnAsync(isbn, cancellationToken);
}
}
Each chain asks for the same book three times. How many log lines do the two chains write in total, and how many queries go to the database?
Why Order Matters
The output:
log -> cache -> db
[log] GetByIsbn 978-0141439518
[log] GetByIsbn 978-0141439518
[log] GetByIsbn 978-0141439518
cache -> log -> db
[log] GetByIsbn 978-0141439518
Database queries: 2
Both chains are made of the same two classes, but they measure different things. With logging outermost, it sees every call: three requests, three log lines, two of which came back from the cache. With logging inside the cache, it sees only the calls that get past the cache: one line, the single query that went to the database. Each chain went to the database once with its own cache, two queries in total.
Which one is right? Both, depending on the question. The answer to "how many times per second does the product page ask the catalog" is in the outer log; the answer to "how much load is on the database" is in the inner one. What matters is knowing that order is a design decision. The same logic applies to other behaviors: an authorization check must sit outside the cache so an unauthorized user can't get cached data; retries must sit inside the cache so there are no needless attempts for a result served from the cache. To read a chain, memorize the rule: the outermost decorator sees the call first and the result last.
The Decorator Pattern
GoF (Gang of Four: the 1994 Design Patterns book by Erich Gamma, Richard Helm, Ralph Johnson and John Vlissides) defines Decorator's intent as attaching additional responsibilities to an object at runtime, offering a flexible alternative to subclassing for extending functionality. Like Adapter, its other name is Wrapper. The book's example is a text view: a border and a scroll bar are added to a TextView without changing it, through decorators wrapped around one another.
The roles are: the component, the interface the client knows (IBookCatalog); the concrete component, the class doing the real work (SqlBookCatalog); and the decorators, classes that implement the same interface and hold a component reference inside (CachingBookCatalog, LoggingBookCatalog). What sets Decorator apart from Adapter is that the interface stays the same: an adapter converts one interface into another, a decorator keeps the same interface and adds behavior. That's why decorators can be stacked as much as you like, and the client can't tell a wrapped object from an unwrapped one.
If you tried to do the same with inheritance, you'd end up with a list of classes like LoggingSqlBookCatalog, CachingSqlBookCatalog, CachingLoggingSqlBookCatalog..., with the order baked into the class name. In the SOLID (Single Responsibility, Open/Closed, Liskov Substitution, Interface Segregation, Dependency Inversion) and Design Principles series post "Composition over Inheritance: Fragile Base Class, Delegation and Pluggable Behavior" we plugged retry, quiet-hours and logging behaviors into notification channels this way; that post compares the same structure with inheritance in detail. In this post let's focus on where Decorator lives in .NET and how it's registered in a real application.
The Stream Family: .NET's Oldest Decorator
System.IO.Stream has been the textbook example of Decorator since the first version of .NET. FileStream and MemoryStream are concrete components; BufferedStream, GZipStream and CryptoStream are decorators that take another Stream in their constructor, wrap it and are a Stream themselves. Let's write an order export to memory, compressed:
using System.IO.Compression;
using System.Text;
string csv = string.Join('\n', Enumerable.Range(1, 1000).Select(i => $"BK-{i:D5};978-0141439518;Emma;9.50"));
// StreamWriter -> GZipStream -> MemoryStream: each layer wraps a Stream
MemoryStream export = new();
using (GZipStream gzip = new(export, CompressionLevel.Optimal))
using (StreamWriter writer = new(gzip, Encoding.UTF8))
writer.Write(csv);
try
{
Console.WriteLine(export.Length);
}
catch (ObjectDisposedException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message}");
}
// leaveOpen: we own the inner stream, the decorator must not close it
MemoryStream kept = new();
using (GZipStream gzip = new(kept, CompressionLevel.Optimal, leaveOpen: true))
using (StreamWriter writer = new(gzip, Encoding.UTF8))
writer.Write(csv);
Console.WriteLine($"{Encoding.UTF8.GetByteCount(csv)} bytes -> {kept.Length} bytes");
kept.Position = 0;
using StreamReader reader = new(new GZipStream(kept, CompressionMode.Decompress));
Console.WriteLine(reader.ReadLine());
ObjectDisposedException: Cannot access a closed Stream.
33999 bytes -> 2363 bytes
BK-00001;978-0141439518;Emma;9.50
Compression brought the 34 KB of CSV (Comma-Separated Values) text down to 2.3 KB, and on the reading side GZipStream, this time in decompress mode, went underneath StreamReader. StreamWriter is unaware of the compression, and GZipStream is unaware of whether the data sits in memory or in a file. If an encryption layer were needed, a CryptoStream would slot in between and none of the other layers would change.
The exception in the first attempt answers Decorator's most often forgotten question: who owns the inner object? When GZipStream is disposed, by default it disposes the stream it wraps too. When we left the using block, export was already closed and we couldn't even ask for its length. If you'll keep using the stream after the decorator, you say with leaveOpen: true that ownership stays with you. If your own decorators wrap an IDisposable component, you have to make the same decision explicitly: either the decorator owns the inner object and disposes it, or ownership stays with whoever created it, usually the DI (dependency injection) container.
Decorators in DI: Manual Registration and Scrutor
In a real ASP.NET Core application the catalog is injected through the constructor and clients ask for IBookCatalog. There are two ways to have the container build the chain. Microsoft.Extensions.DependencyInjection has no ready-made Decorate method; you either build the chain by hand in a factory or use Kristian Hellang's open-source Scrutor library. This time let's add a third decorator to the chain that retries transient database errors:
public sealed record Book(string Isbn, string Title, decimal Price);
public interface IBookCatalog
{
Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default);
}
public sealed class SqlBookCatalog : IBookCatalog
{
private int _calls;
public Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default)
{
// The first query times out, the following ones succeed
if (++_calls == 1)
throw new TimeoutException("Database timeout");
Console.WriteLine($" [db] SELECT ... WHERE Isbn = '{isbn}'");
return Task.FromResult<Book?>(new Book(isbn, "Emma", 9.50m));
}
}
public sealed class RetryingBookCatalog(IBookCatalog inner, int maxAttempts) : IBookCatalog
{
public async Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default)
{
for (int attempt = 1; ; attempt++)
{
try
{
return await inner.GetByIsbnAsync(isbn, cancellationToken);
}
catch (TimeoutException ex) when (attempt < maxAttempts)
{
Console.WriteLine($" [retry] attempt {attempt} failed: {ex.Message}");
}
}
}
}
public sealed class CachingBookCatalog(IBookCatalog inner) : IBookCatalog
{
private readonly Dictionary<string, Book?> _cache = [];
public async Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default)
{
if (_cache.TryGetValue(isbn, out Book? cached))
return cached;
return _cache[isbn] = await inner.GetByIsbnAsync(isbn, cancellationToken);
}
}
public sealed class LoggingBookCatalog(IBookCatalog inner) : IBookCatalog
{
public async Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default)
{
Console.WriteLine($" [log] GetByIsbn {isbn}");
return await inner.GetByIsbnAsync(isbn, cancellationToken);
}
}
using Microsoft.Extensions.DependencyInjection;
// 1) By hand: build the chain inside-out in a factory
ServiceCollection manual = new();
manual.AddSingleton<SqlBookCatalog>();
manual.AddSingleton<IBookCatalog>(sp =>
new LoggingBookCatalog(
new CachingBookCatalog(
new RetryingBookCatalog(sp.GetRequiredService<SqlBookCatalog>(), maxAttempts: 3))));
// 2) Scrutor: each Decorate wraps the previous one, the last is outermost
ServiceCollection scrutor = new();
scrutor.AddSingleton<IBookCatalog, SqlBookCatalog>();
scrutor.Decorate<IBookCatalog>((inner, _) => new RetryingBookCatalog(inner, maxAttempts: 3));
scrutor.Decorate<IBookCatalog, CachingBookCatalog>();
scrutor.Decorate<IBookCatalog, LoggingBookCatalog>();
foreach ((string name, ServiceCollection services) in new[] { ("manual", manual), ("scrutor", scrutor) })
{
using ServiceProvider provider = services.BuildServiceProvider();
IBookCatalog catalog = provider.GetRequiredService<IBookCatalog>();
Console.WriteLine($"{name}: {catalog.GetType().Name}");
for (int i = 0; i < 2; i++)
Console.WriteLine($" -> {(await catalog.GetByIsbnAsync("978-0141439518"))?.Title}");
}
manual: LoggingBookCatalog
[log] GetByIsbn 978-0141439518
[retry] attempt 1 failed: Database timeout
[db] SELECT ... WHERE Isbn = '978-0141439518'
-> Emma
[log] GetByIsbn 978-0141439518
-> Emma
scrutor: LoggingBookCatalog
[log] GetByIsbn 978-0141439518
[retry] attempt 1 failed: Database timeout
[db] SELECT ... WHERE Isbn = '978-0141439518'
-> Emma
[log] GetByIsbn 978-0141439518
-> Emma
Both routes built the same chain: logging outside, the cache in the middle, retry inside and the database innermost. On the first call the database timed out, retry succeeded on the second attempt and the cache stored the result; the second call only touched the log and the cache. With manual registration the order is visible in the nested news and easy to read. In Scrutor each Decorate wraps the previous one, so registration order runs inside-out: the last one added is outermost. Scrutor's gain is that it resolves the decorators' dependencies from the container; if LoggingBookCatalog wanted an ILogger<T>, you wouldn't have to supply it separately. On the manual route you provide the dependencies of everything you new up inside the factory yourself.
Watch the lifetimes: the chain was registered as a singleton, and CachingBookCatalog holds a plain Dictionary. Under concurrent requests that isn't thread-safe; in a real application you'd use ConcurrentDictionary, IMemoryCache or HybridCache, which arrived with .NET 9. Sneakier is a singleton cache decorator wrapping a scoped component, for example a catalog that uses a DbContext: a captive dependency. The C# Advanced series post "Dependency Injection Basics in C#: Lifetimes, the Container and Captive Dependencies" covers this trap in detail. A warning about retry too: the loop here is for showing the pattern; resilience behaviors that need exponential backoff, jitter and circuit breakers in production are the topic of the upcoming External Service Integration and Resilience series.
DelegatingHandler: Decorators in the HttpClient Pipeline
HttpClient's handler chain has the same structure. A DelegatingHandler is an HttpMessageHandler and holds another HttpMessageHandler in its InnerHandler property; calling base.SendAsync passes the request to the inner one:
using System.Net;
// A DelegatingHandler also wraps an inner of the same type (HttpMessageHandler)
HttpMessageHandler pipeline = new CorrelationIdHandler
{
InnerHandler = new LoggingHandler { InnerHandler = new FakeCatalogApi() }
};
using HttpClient client = new(pipeline);
HttpResponseMessage response = await client.GetAsync("https://catalog.bookstore.test/books/978-0141439518");
Console.WriteLine($"status: {(int)response.StatusCode}");
public sealed class CorrelationIdHandler : DelegatingHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
request.Headers.Add("X-Correlation-Id", "c-1001");
return base.SendAsync(request, cancellationToken);
}
}
public sealed class LoggingHandler : DelegatingHandler
{
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Console.WriteLine($"-> {request.Method} {request.RequestUri!.AbsolutePath} ({string.Join(",", request.Headers.GetValues("X-Correlation-Id"))})");
HttpResponseMessage response = await base.SendAsync(request, cancellationToken);
Console.WriteLine($"<- {(int)response.StatusCode}");
return response;
}
}
public sealed class FakeCatalogApi : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK));
}
-> GET /books/978-0141439518 (c-1001)
<- 200
status: 200
Two behaviors, adding a correlation id header and logging the request, were added without touching either HttpClient or the innermost handler. In ASP.NET Core you register this chain with IHttpClientFactory and AddHttpMessageHandler; the standard resilience handler from the Microsoft.Extensions.Http.Resilience package is also a handler added to the chain. Since each handler is also free to choose whether to pass the request on, this chain also resembles Chain of Responsibility; that perspective is in this series' Chain of Responsibility post.
When the Interface Grows: The Default Interface Method Trap
A decorator's contract is to forward every member of the interface to the inner object. When a new member is added to the interface, every decorator has to be updated too; if an abstract member was added, the compiler reminds you. Default interface methods, introduced in C# 8, can silently remove that safety net. The team adding batch reads to the catalog writes the method with a default body so as not to break existing implementations, and writes a single-query override in SqlBookCatalog:
string[] isbns = ["978-0141439518", "978-0547928227", "978-0061120084"];
SqlBookCatalog db = new();
IBookCatalog direct = db;
IBookCatalog decorated = new LoggingBookCatalog(db);
await direct.GetManyAsync(isbns);
Console.WriteLine($"direct: {db.Queries} queries");
db.Queries = 0;
await decorated.GetManyAsync(isbns);
Console.WriteLine($"decorated: {db.Queries} queries");
public sealed record Book(string Isbn, string Title, decimal Price);
public interface IBookCatalog
{
Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default);
// A default interface method added later: existing implementations keep compiling
async Task<IReadOnlyList<Book>> GetManyAsync(IEnumerable<string> isbns, CancellationToken cancellationToken = default)
{
List<Book> books = [];
foreach (string isbn in isbns)
if (await GetByIsbnAsync(isbn, cancellationToken) is { } book)
books.Add(book);
return books;
}
}
public sealed class SqlBookCatalog : IBookCatalog
{
public int Queries { get; set; }
public Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default)
{
Queries++;
return Task.FromResult<Book?>(new Book(isbn, "Title " + isbn[^4..], 9.50m));
}
// Batch read: a single query (WHERE Isbn IN (...))
public Task<IReadOnlyList<Book>> GetManyAsync(IEnumerable<string> isbns, CancellationToken cancellationToken = default)
{
Queries++;
return Task.FromResult<IReadOnlyList<Book>>([.. isbns.Select(i => new Book(i, "Title " + i[^4..], 9.50m))]);
}
}
// A decorator written before the interface changed: it doesn't know GetManyAsync
public sealed class LoggingBookCatalog(IBookCatalog inner) : IBookCatalog
{
public Task<Book?> GetByIsbnAsync(string isbn, CancellationToken cancellationToken = default)
{
Console.WriteLine($" [log] GetByIsbn {isbn}");
return inner.GetByIsbnAsync(isbn, cancellationToken);
}
}
direct: 1 queries
[log] GetByIsbn 978-0141439518
[log] GetByIsbn 978-0547928227
[log] GetByIsbn 978-0061120084
decorated: 3 queries
The code compiled without warnings. One query for the direct call, three through the decorator. Since LoggingBookCatalog doesn't implement GetManyAsync, the call fell through to the interface's default body; that body in turn called the decorator's GetByIsbnAsync once per book. SqlBookCatalog's batch query became invisible behind the decorator. On an order history page listing a hundred books, that means a hundred queries instead of one. The fix is to forward the new member to inner explicitly in the decorators; and to be reminded of that, use default interface methods carefully on interfaces that have decorators, or derive the decorators from a base class that does the forwarding in one place.
Common Pitfalls
Getting lost in a deep chain. In a chain of five decorators you step into the debugger five times to find where a call goes; stack traces grow longer. Instead of writing a decorator for every behavior, separate out only the behaviors that really need to be switched on and off independently.
Relying on object identity. GoF warns about this too: a decorator and the component it wraps aren't the same object. A catalog is SqlBookCatalog check or reference equality gives the wrong answer the day a decorator is added.
Not documenting order dependencies. An ordering mistake like moving the cache outside authorization doesn't cause a compile error; it shows up as a security hole. Build the chain in a single composition root and write a test that checks the order.
Forgetting to forward. Not passing the CancellationToken to the inner call, logging the result in an async method without awaiting it, or, as above, not forwarding a new member. A decorator's default behavior should be "pass it on unchanged".
Putting business rules in a decorator. Once a rule like "apply the campaign discount to the price of a book coming from the cache" gets into a decorator, the rule depends on how the chain is built; a test without the decorator or a different chain skips the rule. Decorators are for cross-cutting concerns: logging, caching, retries, metrics, authorization.
Confusing it with Proxy. The Proxy pattern, which looks structurally the same but whose intent is to control access, is the topic of an upcoming post in this series; roughly, a decorator adds behavior, a proxy manages access.
Writing a decorator for a one-off behavior. If a behavior is needed for only one component, in one place and always together with it, writing it as two lines inside the component is cheaper than a decorator class, its registration and an ordering decision. Decorator earns its keep where the same behavior can be plugged into several components, or switched on and off per environment.
A Small Experiment
★☆☆ Add a TimingBookCatalog to the opening example: it measures and prints the duration of every call with a Stopwatch. Put it first at the outermost position of the chain, then between the cache and the database, and compare what the two measurements tell you.
★★☆ Rewrite CachingBookCatalog to use IMemoryCache and drop entries after five minutes; should you cache null results (an ISBN (International Standard Book Number) that wasn't found)? Pin down your decision with a test.
★★★ To prevent the default interface method trap, write an abstract base class called BookCatalogDecorator that forwards every member of the interface to inner as a virtual. Then add a new member to the interface and count how many decorators you have to update. How does this solution relate to the fragile base class risk we discussed in the SOLID and Design Principles series?
Decorator added layers around an object while keeping the same interface. In the next post we move to a pattern that does the opposite: Facade, which puts several subsystems behind a single, simple interface, reducing the order checkout flow's stock, payment, invoice and notification calls to a single door.