Structural Intermediate 8 min read

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
  1. What problem does the Decorator pattern solve in C#?
  2. How does a basic Decorator class look in .NET 10?
  3. How do you compose Decorators with DI in modern .NET?
  4. When does a hand-written Decorator earn its keep?
  5. How does Decorator compare to Adapter and Proxy?
  6. What does a real .NET 10 example look like?
  7. 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:

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:

  1. Pipeline-style wrapping. Logging, retry, idempotency, metrics, caching, validation. Each is a single concern; together they form a chain.
  2. Optional features. Caching that should be on in production but off in tests; verbose logging that toggles per environment.
  3. Provider-agnostic concerns. Retry should work the same for StripeProcessor and PaypalProcessor. 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:

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.

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?
Middleware is Decorator at HTTP-pipeline scale. ASP.NET Core's 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?
Scrutor is a small NuGet package that adds 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.
Should I write a Decorator or use a polymorphic base class?
Use a Decorator when the cross-cutting behaviour is optional or composable — you want logging on some registrations and retry on others, possibly together. Use a base class when the behaviour is shared across every implementation and never composed. The decorator lets every implementation stay simple; inheritance forces every implementation to inherit the cross-cutting concerns whether it wants them or not.
How does Decorator compare to Adapter and Proxy?
All three wrap one object behind a class. Adapter changes the interface; Decorator keeps the interface and adds behaviour; Proxy keeps the interface and controls access. Adapter is for fitting a foreign type; Decorator is for adding cross-cutting concerns; Proxy is for lazy loading, security, or remote calls.