Decorator Pattern in C#: Logging, Retry, Caching Wrappers
Decorator pattern in C# / .NET 10: wrap a service with logging, retry, and caching without modifying it, and how Scrutor.Decorate automates the registration.
Table of contents
- What problem does the Decorator pattern solve in C#?
- How does a basic Decorator class look in .NET 10?
- How do you compose Decorators with DI in modern .NET?
- When does a hand-written Decorator earn its keep?
- How does Decorator compare to Adapter and Proxy?
- What does a real .NET 10 example look like?
- Where should you read next in this series?
The IPaymentProcessor from the Factory
Method chapter charges a card. Six
months in, the team needs four cross-cutting concerns around that one
call:
- Logging. Every charge must emit a structured log line.
- Retry. Transient errors should retry up to three times with exponential backoff.
- Idempotency. A duplicate charge with the same idempotency key should be a no-op.
- Fraud check. Suspicious charges (large amounts to new customers) should be flagged for manual review before the call.
The wrong instinct is to add all four into StripeProcessor itself.
Suddenly the class doing the SDK call is also the class doing logging,
retry, idempotency, and fraud detection. The next provider class
(PaypalProcessor) gets the same treatment, copy-pasted. Six months
later, fixing a retry bug means visiting every implementation.
The Decorator pattern is the answer. Each cross-cutting concern
lives in its own small class that wraps an IPaymentProcessor and
itself implements IPaymentProcessor. Concerns are stacked at
registration time. Each one is independently testable. Removing or
re-ordering a concern is one line in Program.cs.
What problem does the Decorator pattern solve in C#?
The pattern earns its place when you want to add cross-cutting behaviour to an interface without modifying any concrete implementation. Three concrete shapes:
- Pipeline-style wrapping. Logging, retry, idempotency, metrics, caching, validation. Each is a single concern; together they form a chain.
- Optional features. Caching that should be on in production but off in tests; verbose logging that toggles per environment.
- Provider-agnostic concerns. Retry should work the same for
StripeProcessorandPaypalProcessor. Writing it once as a decorator avoids two copies.
What is not a Decorator problem: "I want to wrap a class with the wrong interface" — that is Adapter. "I want to fan out to many objects" — that is Composite. Decorator is specifically about one-to-one wrapping that adds behaviour.
How does a basic Decorator class look in .NET 10?
A logging decorator is the simplest example — same interface, calls through, adds before/after work:
public sealed class LoggingPaymentProcessor : IPaymentProcessor
{
private readonly IPaymentProcessor _inner;
private readonly ILogger<LoggingPaymentProcessor> _log;
public LoggingPaymentProcessor(IPaymentProcessor inner,
ILogger<LoggingPaymentProcessor> log)
=> (_inner, _log) = (inner, log);
public async Task<PaymentResult> PayAsync(decimal amount, string customerId,
string idemKey, CancellationToken ct)
{
_log.LogInformation("Charging {Amount} for {Customer} (key {Key})",
amount, customerId, idemKey);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var result = await _inner.PayAsync(amount, customerId, idemKey, ct);
_log.LogInformation("Charge {Status} in {Ms}ms (tx {Tx})",
result.Status, sw.ElapsedMilliseconds, result.TransactionId);
return result;
}
catch (Exception ex)
{
_log.LogError(ex, "Charge failed for {Customer}", customerId);
throw;
}
}
}
Other decorators follow the same shape. A retry decorator:
public sealed class RetryingPaymentProcessor : IPaymentProcessor
{
private readonly IPaymentProcessor _inner;
private readonly int _maxAttempts;
public RetryingPaymentProcessor(IPaymentProcessor inner, int maxAttempts = 3)
=> (_inner, _maxAttempts) = (inner, maxAttempts);
public async Task<PaymentResult> PayAsync(decimal amount, string customerId,
string idemKey, CancellationToken ct)
{
for (var attempt = 1; ; attempt++)
{
try { return await _inner.PayAsync(amount, customerId, idemKey, ct); }
catch (TransientException) when (attempt < _maxAttempts)
{
await Task.Delay(TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt - 1)), ct);
}
}
}
}
Every decorator is also an IPaymentProcessor, so it can be wrapped
again. The structural picture:
classDiagram
class IPaymentProcessor {
<<interface>>
+PayAsync(amount, customer, key) PaymentResult
}
class StripeProcessor
class LoggingPaymentProcessor
class RetryingPaymentProcessor
class FraudCheckPaymentProcessor
IPaymentProcessor <|.. StripeProcessor
IPaymentProcessor <|.. LoggingPaymentProcessor
IPaymentProcessor <|.. RetryingPaymentProcessor
IPaymentProcessor <|.. FraudCheckPaymentProcessor
LoggingPaymentProcessor o-- IPaymentProcessor : wraps
RetryingPaymentProcessor o-- IPaymentProcessor : wraps
FraudCheckPaymentProcessor o-- IPaymentProcessor : wraps
Notice that the picture says nothing about which IPaymentProcessor
each decorator wraps — that is decided at registration time.
How do you compose Decorators with DI in modern .NET?
Hand-rolling the registration is verbose:
builder.Services.AddSingleton<StripeProcessor>();
builder.Services.AddSingleton<IPaymentProcessor>(sp =>
{
IPaymentProcessor inner = sp.GetRequiredService<StripeProcessor>();
inner = new LoggingPaymentProcessor(inner, sp.GetRequiredService<ILogger<LoggingPaymentProcessor>>());
inner = new RetryingPaymentProcessor(inner);
inner = new FraudCheckPaymentProcessor(inner, sp.GetRequiredService<IFraudService>());
return inner;
});
This is correct but tedious. The community-standard library
Scrutor adds services.Decorate<TInterface, TDecorator>() to make
the same chain readable:
builder.Services.AddSingleton<IPaymentProcessor, StripeProcessor>();
builder.Services.Decorate<IPaymentProcessor, FraudCheckPaymentProcessor>();
builder.Services.Decorate<IPaymentProcessor, RetryingPaymentProcessor>();
builder.Services.Decorate<IPaymentProcessor, LoggingPaymentProcessor>();
Reading top-to-bottom, the outermost decorator is the last one
registered. So a charge call goes: Logging → Retrying → FraudCheck →
StripeProcessor. The order of .Decorate calls is the order from
inside out.
For projects that do not want a Scrutor dependency, the manual registration above is the same thing. Both compile to the identical chain.
When does a hand-written Decorator earn its keep?
AddSingleton plus Decorate covers most cases. There are still
three reasons to write the wrapper class explicitly rather than reach
for a framework abstraction:
- Stateful decoration. A circuit-breaker decorator holds state (failure count, open-since timestamp) that a stateless middleware cannot. The class is the natural home.
- Type-specific behaviour. Logging structured fields like
OrderIdrequires the decorator to know the method signature. A generic AOP framework would type-erase this. - Conditional decoration. Wrap with retry only for Stripe, not for COD. The if-statement lives in the registration code, but the decorator class is still hand-written.
When in doubt, write the decorator. It is fifteen lines, it is easy to understand, and it is easy to delete when no longer needed.
How does Decorator compare to Adapter and Proxy?
| Pattern | Same interface as wrapped? | Adds behaviour? | Controls access? |
|---|---|---|---|
| Decorator | Yes | Yes (logging, retry, etc.) | No |
| Adapter | No (changes interface) | No | No |
| Proxy | Yes | No (or transparent forwarding) | Yes (lazy load, security, remote) |
The clearest sentence: Decorator adds behaviour, Adapter changes interface, Proxy controls access. The three look identical at the class definition level — they all hold a reference to the same interface they implement — and the only difference is what their methods do. The intent is the differentiator.
What does a real .NET 10 example look like?
A complete shape that ships in a real codebase. Notice the
Decorate order: from outer to inner is logging → retry → fraud
check → real Stripe call.
public interface IPaymentProcessor
{
Task<PaymentResult> PayAsync(decimal amount, string customerId, string idemKey, CancellationToken ct);
}
public sealed class StripeProcessor : IPaymentProcessor { /* SDK call */ }
// Decorator 1: structured logging
public sealed class LoggingPaymentProcessor : IPaymentProcessor
{
private readonly IPaymentProcessor _inner;
private readonly ILogger<LoggingPaymentProcessor> _log;
public LoggingPaymentProcessor(IPaymentProcessor inner, ILogger<LoggingPaymentProcessor> log)
=> (_inner, _log) = (inner, log);
public async Task<PaymentResult> PayAsync(decimal a, string c, string k, CancellationToken ct)
{
using var scope = _log.BeginScope(new Dictionary<string, object>
{ ["customer"] = c, ["idem"] = k });
_log.LogInformation("Charge start: {Amount}", a);
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
var result = await _inner.PayAsync(a, c, k, ct);
_log.LogInformation("Charge {Status} in {Ms}ms", result.Status, sw.ElapsedMilliseconds);
return result;
}
catch (Exception ex) { _log.LogError(ex, "Charge failed"); throw; }
}
}
// Decorator 2: retry on transient errors
public sealed class RetryingPaymentProcessor : IPaymentProcessor
{
private readonly IPaymentProcessor _inner;
public RetryingPaymentProcessor(IPaymentProcessor inner) => _inner = inner;
public async Task<PaymentResult> PayAsync(decimal a, string c, string k, CancellationToken ct)
{
for (var attempt = 1; ; attempt++)
{
try { return await _inner.PayAsync(a, c, k, ct); }
catch (TransientException) when (attempt < 3)
{
await Task.Delay(TimeSpan.FromMilliseconds(200 * Math.Pow(2, attempt - 1)), ct);
}
}
}
}
// Decorator 3: fraud check before charging
public sealed class FraudCheckPaymentProcessor : IPaymentProcessor
{
private readonly IPaymentProcessor _inner;
private readonly IFraudService _fraud;
public FraudCheckPaymentProcessor(IPaymentProcessor inner, IFraudService fraud)
=> (_inner, _fraud) = (inner, fraud);
public async Task<PaymentResult> PayAsync(decimal a, string c, string k, CancellationToken ct)
{
if (await _fraud.IsSuspiciousAsync(c, a, ct))
return new PaymentResult("blocked", null, "Flagged for fraud review");
return await _inner.PayAsync(a, c, k, ct);
}
}
// Composition root (with Scrutor)
builder.Services.AddSingleton<IFraudService, FraudService>();
builder.Services.AddSingleton<IPaymentProcessor, StripeProcessor>();
builder.Services.Decorate<IPaymentProcessor, FraudCheckPaymentProcessor>();
builder.Services.Decorate<IPaymentProcessor, RetryingPaymentProcessor>();
builder.Services.Decorate<IPaymentProcessor, LoggingPaymentProcessor>();
// Caller injects IPaymentProcessor — has no idea about the wrapping
public sealed class CheckoutController
{
private readonly IPaymentProcessor _payment;
public CheckoutController(IPaymentProcessor payment) => _payment = payment;
// ...
}
// Test: bypass all decorators, just exercise StripeProcessor
[Fact]
public async Task Stripe_charges_on_happy_path()
{
var sut = new StripeProcessor(/* fakes */);
var result = await sut.PayAsync(9.99m, "cust_1", "key_1", default);
Assert.Equal("succeeded", result.Status);
}
// Test: confirm the fraud decorator blocks suspicious charges
[Fact]
public async Task Fraud_decorator_blocks_suspicious_charge()
{
var fraud = new Mock<IFraudService>();
fraud.Setup(f => f.IsSuspiciousAsync(It.IsAny<string>(), It.IsAny<decimal>(), default))
.ReturnsAsync(true);
var sut = new FraudCheckPaymentProcessor(new StripeProcessor(/* ... */), fraud.Object);
var result = await sut.PayAsync(50_000m, "new_customer", "k", default);
Assert.Equal("blocked", result.Status);
}
What you do not see: any concrete class with multiple cross-cutting
concerns mixed in. StripeProcessor does one thing: call Stripe.
Each decorator does one thing. The chain is configured once in
Program.cs and never edited again unless the policy itself changes.
Where should you read next in this series?
- Previous: Composite — same interface shape, but for one-to-many tree structures rather than one-to-one wrapping.
- Next: Facade — when the goal is to hide several objects behind a single simpler one.
- Cross-reference: Adapter — same shape (one wraps one), different intent (changes interface vs adds behaviour).
- Cross-reference: Chain of Responsibility — when the wrappers can stop the chain (validation, authorisation) rather than always forward, you have crossed into CoR.
- Decision tree: How to choose the right design pattern.
A pragmatic note on tooling: prefer per-call cross-cutting concerns as Decorators over global concerns as middleware when you want them to apply to one service rather than every HTTP request. Middleware is excellent at the request boundary; the Decorator pattern is what takes you from there into the dependency graph.
Frequently asked questions
What is the difference between Decorator and middleware?
app.Use(...) builds a chain of wrappers around a request handler — exactly the Decorator pattern, just with a fixed RequestDelegate shape. The Decorator pattern at class scope generalises the same idea to any interface: IPaymentProcessor, IOrderRepository, anything you want to wrap.How does Scrutor's Decorate method automate Decorator registration?
services.Decorate<TInterface, TDecorator>() to IServiceCollection. Behind the scenes it replaces the existing registration with the decorator and injects the original implementation into the decorator's constructor. The result: composing four decorators around one service is four lines instead of forty.