BrandGhost
Decorator Pattern Real-World Example in C#: Complete Implementation

Decorator Pattern Real-World Example in C#: Complete Implementation

Decorator Pattern Real-World Example in C#: Complete Implementation

Most decorator pattern tutorials wrap a "Pizza" or "Coffee" class with toppings and call it a day. That's fine for learning the mechanics, but it doesn't help you solve the problems you actually run into at work. This article takes a completely different approach. We'll build a decorator pattern real-world example in C# from scratch -- a production-grade HTTP client service layered with logging, caching, retry logic, and circuit breaker protection.

By the end, you'll have a complete, compilable implementation that demonstrates how the decorator pattern transforms cross-cutting concerns from tangled if-statements into clean, composable layers. Each decorator handles exactly one responsibility, and you can mix, match, and reorder them depending on what your application needs. This is the kind of architecture you'd find in a well-designed .NET microservice, and it's the pattern that makes that architecture possible.

The Problem: Cross-Cutting Concerns in HTTP Clients

If you've ever built a service that calls external APIs, you've run into this exact scenario. You start with a simple HttpClient call. Then someone asks for logging. Then caching for GET requests. Then retry logic for transient failures. Then a circuit breaker so you stop hammering a service that's already down.

Here's what that looks like without the decorator pattern:

public class ApiClient
{
    private readonly HttpClient _httpClient;
    private readonly ILogger _logger;
    private readonly IMemoryCache _cache;
    private int _failureCount;
    private DateTimeOffset _circuitOpenedAt;

    public async Task<string> GetAsync(string url)
    {
        // Circuit breaker check
        if (_failureCount >= 3 &&
            DateTimeOffset.UtcNow - _circuitOpenedAt
                < TimeSpan.FromSeconds(30))
        {
            throw new Exception("Circuit is open");
        }

        // Cache check
        if (_cache.TryGetValue(url, out string? cached))
        {
            _logger.LogInformation(
                "Cache hit for {Url}", url);
            return cached!;
        }

        // Retry logic
        for (int attempt = 0; attempt < 3; attempt++)
        {
            try
            {
                _logger.LogInformation(
                    "GET {Url} attempt {Attempt}",
                    url, attempt + 1);

                var response = await _httpClient
                    .GetStringAsync(url);

                _cache.Set(url, response,
                    TimeSpan.FromMinutes(5));
                _failureCount = 0;

                return response;
            }
            catch (HttpRequestException) when (
                attempt < 2)
            {
                await Task.Delay(
                    (int)Math.Pow(2, attempt) * 1000);
            }
        }

        _failureCount++;
        if (_failureCount >= 3)
        {
            _circuitOpenedAt = DateTimeOffset.UtcNow;
        }

        throw new Exception(
            "All retry attempts exhausted");
    }
}

This single class is responsible for HTTP communication, logging, caching, retry logic, and circuit breaking. Every concern is tangled together, and modifying any one of them risks breaking the others. Testing is a nightmare because you can't isolate any single behavior. Need to change the retry policy? You're editing the same file that handles caching. Want to disable the circuit breaker in a specific environment? Good luck pulling that logic apart.

The decorator pattern solves this by separating each concern into its own class. Let's build it.

Designing the IApiClient Interface

Every decorator pattern real-world example in C# starts with a clean interface. This contract is the foundation that both the core implementation and every decorator will share. Because they all implement the same interface, client code never needs to know how many layers of decoration are in play:

using System.Net.Http;

public interface IApiClient
{
    Task<string> GetAsync(string url);

    Task<string> PostAsync(
        string url,
        string content);

    Task<string> PutAsync(
        string url,
        string content);

    Task DeleteAsync(string url);
}

The interface is intentionally focused. Four methods covering the standard HTTP verbs give us enough surface area to demonstrate the decorator pattern without creating excessive boilerplate. In a production system, you might add methods for PATCH requests, streaming responses, or typed deserialization -- but the pattern works the same way regardless of the interface size. If you've worked with middleware in ASP.NET Core, you'll notice a similar philosophy here: each layer does one thing and passes control to the next.

Building the Core Implementation

The concrete component is where the actual HTTP communication happens. This class wraps HttpClient and provides a straightforward implementation of every method on the IApiClient interface:

using System.Net.Http;
using System.Text;

public sealed class HttpApiClient : IApiClient
{
    private readonly HttpClient _httpClient;

    public HttpApiClient(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<string> GetAsync(string url)
    {
        var response = await _httpClient.GetAsync(url);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    }

    public async Task<string> PostAsync(
        string url,
        string content)
    {
        var body = new StringContent(
            content,
            Encoding.UTF8,
            "application/json");

        var response = await _httpClient.PostAsync(
            url, body);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    }

    public async Task<string> PutAsync(
        string url,
        string content)
    {
        var body = new StringContent(
            content,
            Encoding.UTF8,
            "application/json");

        var response = await _httpClient.PutAsync(
            url, body);
        response.EnsureSuccessStatusCode();
        return await response.Content.ReadAsStringAsync();
    }

    public async Task DeleteAsync(string url)
    {
        var response = await _httpClient.DeleteAsync(url);
        response.EnsureSuccessStatusCode();
    }
}

Notice that HttpApiClient does exactly one thing: it makes HTTP calls. There's no logging, no caching, no retry logic. This class is easy to understand, easy to test against a mock HttpClient, and it follows the Single Responsibility Principle. All the cross-cutting concerns will be layered on top through decorators, which is exactly the approach the decorator pattern was designed for.

Decorator 1: Logging

The first layer in our decorator pattern real-world example in C# is logging. The logging decorator wraps any IApiClient implementation and adds structured logging before and after every call. It delegates all actual work to the inner client, which means it has no idea whether it's wrapping the core HttpApiClient or another decorator:

using System.Diagnostics;

using Microsoft.Extensions.Logging;

public sealed class LoggingApiClientDecorator : IApiClient
{
    private readonly IApiClient _inner;
    private readonly ILogger<LoggingApiClientDecorator> _logger;

    public LoggingApiClientDecorator(
        IApiClient inner,
        ILogger<LoggingApiClientDecorator> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public async Task<string> GetAsync(string url)
    {
        var stopwatch = Stopwatch.StartNew();
        _logger.LogInformation(
            "GET {Url} -- starting request", url);

        try
        {
            var result = await _inner.GetAsync(url);

            stopwatch.Stop();
            _logger.LogInformation(
                "GET {Url} -- completed in {Elapsed}ms",
                url, stopwatch.ElapsedMilliseconds);

            return result;
        }
        catch (Exception ex)
        {
            stopwatch.Stop();
            _logger.LogError(
                ex,
                "GET {Url} -- failed after {Elapsed}ms",
                url, stopwatch.ElapsedMilliseconds);
            throw;
        }
    }

    public async Task<string> PostAsync(
        string url,
        string content)
    {
        _logger.LogInformation(
            "POST {Url} -- starting request", url);

        try
        {
            var result = await _inner.PostAsync(
                url, content);

            _logger.LogInformation(
                "POST {Url} -- completed", url);
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex, "POST {Url} -- failed", url);
            throw;
        }
    }

    public async Task<string> PutAsync(
        string url,
        string content)
    {
        _logger.LogInformation(
            "PUT {Url} -- starting request", url);

        try
        {
            var result = await _inner.PutAsync(
                url, content);

            _logger.LogInformation(
                "PUT {Url} -- completed", url);
            return result;
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex, "PUT {Url} -- failed", url);
            throw;
        }
    }

    public async Task DeleteAsync(string url)
    {
        _logger.LogInformation(
            "DELETE {Url} -- starting request", url);

        try
        {
            await _inner.DeleteAsync(url);

            _logger.LogInformation(
                "DELETE {Url} -- completed", url);
        }
        catch (Exception ex)
        {
            _logger.LogError(
                ex, "DELETE {Url} -- failed", url);
            throw;
        }
    }
}

The pattern here is consistent across all four methods: log before, delegate to the inner client, log after (or log the error). The Stopwatch on GetAsync demonstrates how you can add timing information to specific operations. In a real application, you might apply that to every method or extract it into a helper. The important thing is that logging is completely isolated in this class -- the core HttpApiClient doesn't know it's being logged, and no other decorator cares either.

Decorator 2: Caching

The caching decorator intercepts GET requests and stores responses in an IMemoryCache. Non-GET methods bypass the cache entirely and also invalidate cached entries when they target the same base URL. This mirrors how real HTTP caching works -- GET requests are safe to cache, but writes should invalidate stale data:

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

public sealed class CachingApiClientDecorator : IApiClient
{
    private readonly IApiClient _inner;
    private readonly IMemoryCache _cache;
    private readonly ILogger<CachingApiClientDecorator> _logger;
    private readonly TimeSpan _cacheDuration;

    public CachingApiClientDecorator(
        IApiClient inner,
        IMemoryCache cache,
        ILogger<CachingApiClientDecorator> logger,
        TimeSpan? cacheDuration = null)
    {
        _inner = inner;
        _cache = cache;
        _logger = logger;
        _cacheDuration = cacheDuration
            ?? TimeSpan.FromMinutes(5);
    }

    public async Task<string> GetAsync(string url)
    {
        if (_cache.TryGetValue(url, out string? cached))
        {
            _logger.LogDebug(
                "Cache hit for {Url}", url);
            return cached!;
        }

        _logger.LogDebug(
            "Cache miss for {Url}", url);
        var result = await _inner.GetAsync(url);

        _cache.Set(
            url,
            result,
            new MemoryCacheEntryOptions
            {
                AbsoluteExpirationRelativeToNow =
                    _cacheDuration
            });

        return result;
    }

    public async Task<string> PostAsync(
        string url,
        string content)
    {
        var result = await _inner.PostAsync(url, content);
        InvalidateRelatedEntries(url);
        return result;
    }

    public async Task<string> PutAsync(
        string url,
        string content)
    {
        var result = await _inner.PutAsync(url, content);
        InvalidateRelatedEntries(url);
        return result;
    }

    public async Task DeleteAsync(string url)
    {
        await _inner.DeleteAsync(url);
        InvalidateRelatedEntries(url);
    }

    private void InvalidateRelatedEntries(string url)
    {
        _cache.Remove(url);
        _logger.LogDebug(
            "Invalidated cache for {Url}", url);
    }
}

The caching decorator is a great example of why the decorator pattern is so useful for cross-cutting concerns. The caching logic lives in one place, it's easy to swap out the cache implementation, and you can adjust the cache duration through configuration. If you want to disable caching, you simply don't include this decorator in the chain. Compare that to the monolithic approach from the introduction where caching was woven into the same class that handled retries and circuit breaking.

Decorator 3: Retry with Exponential Backoff

Transient failures are inevitable when calling external APIs. Network blips, momentary server overloads, and brief service restarts all produce errors that resolve themselves if you wait and try again. This decorator implements retry logic with exponential backoff -- no external libraries required:

using System.Net.Http;

using Microsoft.Extensions.Logging;

public sealed class RetryApiClientDecorator : IApiClient
{
    private readonly IApiClient _inner;
    private readonly ILogger<RetryApiClientDecorator> _logger;
    private readonly int _maxRetries;
    private readonly TimeSpan _baseDelay;

    public RetryApiClientDecorator(
        IApiClient inner,
        ILogger<RetryApiClientDecorator> logger,
        int maxRetries = 3,
        TimeSpan? baseDelay = null)
    {
        _inner = inner;
        _logger = logger;
        _maxRetries = maxRetries;
        _baseDelay = baseDelay ?? TimeSpan.FromSeconds(1);
    }

    public Task<string> GetAsync(string url) =>
        ExecuteWithRetryAsync(() => _inner.GetAsync(url),
            "GET", url);

    public Task<string> PostAsync(
        string url,
        string content) =>
        ExecuteWithRetryAsync(
            () => _inner.PostAsync(url, content),
            "POST", url);

    public Task<string> PutAsync(
        string url,
        string content) =>
        ExecuteWithRetryAsync(
            () => _inner.PutAsync(url, content),
            "PUT", url);

    public async Task DeleteAsync(string url)
    {
        await ExecuteWithRetryAsync(async () =>
        {
            await _inner.DeleteAsync(url);
            return string.Empty;
        }, "DELETE", url);
    }

    private async Task<string> ExecuteWithRetryAsync(
        Func<Task<string>> operation,
        string method,
        string url)
    {
        for (int attempt = 1;
            attempt <= _maxRetries;
            attempt++)
        {
            try
            {
                return await operation();
            }
            catch (HttpRequestException ex)
                when (attempt < _maxRetries)
            {
                var delay = TimeSpan.FromMilliseconds(
                    _baseDelay.TotalMilliseconds
                    * Math.Pow(2, attempt - 1));

                _logger.LogWarning(
                    ex,
                    "{Method} {Url} -- attempt {Attempt} " +
                    "failed, retrying in {Delay}ms",
                    method,
                    url,
                    attempt,
                    delay.TotalMilliseconds);

                await Task.Delay(delay);
            }
        }

        return await operation();
    }
}

A few important design decisions here. The ExecuteWithRetryAsync helper keeps the retry logic in one place instead of duplicating it across all four methods. The when clause in the catch block ensures we only retry HttpRequestException -- the kind of exception that signals a transient network problem. Other exceptions like ArgumentException or InvalidOperationException propagate immediately because retrying them won't help.

The exponential backoff calculation uses Math.Pow(2, attempt - 1), which produces delays of 1 second, 2 seconds, and 4 seconds with the default base delay. This gives the remote service time to recover without hammering it with immediate retries. You can also think of this as a simpler take on what you'd see in a pipeline design pattern -- each step in the pipeline has clear responsibility, and the retry logic is just one of those steps.

Decorator 4: Circuit Breaker

The circuit breaker pattern prevents your application from repeatedly calling a service that's down, which would waste resources and slow down your own response times. It works like an electrical circuit breaker: after a threshold of consecutive failures, it "opens" the circuit and short-circuits all requests for a cooldown period. After the cooldown, it lets one request through to test whether the service has recovered:

using Microsoft.Extensions.Logging;

public sealed class CircuitBreakerApiClientDecorator
    : IApiClient
{
    private readonly IApiClient _inner;
    private readonly ILogger<CircuitBreakerApiClientDecorator>
        _logger;
    private readonly int _failureThreshold;
    private readonly TimeSpan _cooldownPeriod;

    private int _consecutiveFailures;
    private DateTimeOffset _circuitOpenedAt;
    private CircuitState _state = CircuitState.Closed;
    private readonly object _lock = new();

    private enum CircuitState
    {
        Closed,
        Open,
        HalfOpen
    }

    public CircuitBreakerApiClientDecorator(
        IApiClient inner,
        ILogger<CircuitBreakerApiClientDecorator> logger,
        int failureThreshold = 3,
        TimeSpan? cooldownPeriod = null)
    {
        _inner = inner;
        _logger = logger;
        _failureThreshold = failureThreshold;
        _cooldownPeriod = cooldownPeriod
            ?? TimeSpan.FromSeconds(30);
    }

    public Task<string> GetAsync(string url) =>
        ExecuteWithCircuitBreakerAsync(
            () => _inner.GetAsync(url));

    public Task<string> PostAsync(
        string url,
        string content) =>
        ExecuteWithCircuitBreakerAsync(
            () => _inner.PostAsync(url, content));

    public Task<string> PutAsync(
        string url,
        string content) =>
        ExecuteWithCircuitBreakerAsync(
            () => _inner.PutAsync(url, content));

    public async Task DeleteAsync(string url)
    {
        await ExecuteWithCircuitBreakerAsync(async () =>
        {
            await _inner.DeleteAsync(url);
            return string.Empty;
        });
    }

    private async Task<string>
        ExecuteWithCircuitBreakerAsync(
            Func<Task<string>> operation)
    {
        EnsureCircuitAllowsRequest();

        try
        {
            var result = await operation();
            OnSuccess();
            return result;
        }
        catch (Exception ex)
        {
            OnFailure(ex);
            throw;
        }
    }

    private void EnsureCircuitAllowsRequest()
    {
        lock (_lock)
        {
            switch (_state)
            {
                case CircuitState.Open:
                    if (DateTimeOffset.UtcNow - _circuitOpenedAt
                        >= _cooldownPeriod)
                    {
                        _logger.LogInformation(
                            "Circuit transitioning to " +
                            "half-open -- allowing " +
                            "test request");
                        _state = CircuitState.HalfOpen;
                    }
                    else
                    {
                        _logger.LogWarning(
                            "Circuit is open -- " +
                            "rejecting request");
                        throw new CircuitBreakerOpenException(
                            "Circuit breaker is open. " +
                            "Requests are being rejected.");
                    }
                    break;

                case CircuitState.HalfOpen:
                    _logger.LogDebug(
                        "Circuit is half-open -- " +
                        "allowing test request");
                    break;

                case CircuitState.Closed:
                    break;
            }
        }
    }

    private void OnSuccess()
    {
        lock (_lock)
        {
            if (_state == CircuitState.HalfOpen)
            {
                _logger.LogInformation(
                    "Test request succeeded -- " +
                    "closing circuit");
            }

            _consecutiveFailures = 0;
            _state = CircuitState.Closed;
        }
    }

    private void OnFailure(Exception ex)
    {
        lock (_lock)
        {
            _consecutiveFailures++;

            if (_state == CircuitState.HalfOpen ||
                _consecutiveFailures >= _failureThreshold)
            {
                _state = CircuitState.Open;
                _circuitOpenedAt = DateTimeOffset.UtcNow;

                _logger.LogError(
                    ex,
                    "Circuit opened after " +
                    "{Failures} consecutive failures",
                    _consecutiveFailures);
            }
            else
            {
                _logger.LogWarning(
                    ex,
                    "Request failed -- " +
                    "{Failures}/{Threshold} failures",
                    _consecutiveFailures,
                    _failureThreshold);
            }
        }
    }
}

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

The circuit breaker uses a three-state machine: Closed (normal operation), Open (all requests rejected), and HalfOpen (letting one test request through). Thread safety is handled with a simple lock since the state transitions need to be atomic. In a high-throughput system, you could replace this with Interlocked operations or a SemaphoreSlim, but the lock-based approach is clear and correct for most scenarios.

This decorator has no knowledge of logging, caching, or retry logic. It only knows about failures and state transitions. That isolation is the entire point of using the decorator pattern for cross-cutting concerns. If you want to learn more about similar resilience strategies, check out how custom middleware in ASP.NET Core handles similar pipeline scenarios.

Wiring It All Together

Now comes the part where this decorator pattern real-world example in C# really shines: composing the decorators into a pipeline. The order in which you wrap decorators matters, and understanding that order is critical to getting the behavior you want. Here's how to register everything with the .NET dependency injection container:

using Microsoft.Extensions.DependencyInjection;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddMemoryCache();
builder.Services.AddHttpClient();
builder.Services.AddLogging();

builder.Services.AddSingleton<IApiClient>(sp =>
{
    var httpClient = sp
        .GetRequiredService<IHttpClientFactory>()
        .CreateClient();
    var cache = sp
        .GetRequiredService<IMemoryCache>();
    var loggerFactory = sp
        .GetRequiredService<ILoggerFactory>();

    // Layer 1: Core HTTP client
    IApiClient client = new HttpApiClient(httpClient);

    // Layer 2: Retry transient failures
    client = new RetryApiClientDecorator(
        client,
        loggerFactory
            .CreateLogger<RetryApiClientDecorator>(),
        maxRetries: 3,
        baseDelay: TimeSpan.FromSeconds(1));

    // Layer 3: Circuit breaker wraps retry
    client = new CircuitBreakerApiClientDecorator(
        client,
        loggerFactory
            .CreateLogger<CircuitBreakerApiClientDecorator>(),
        failureThreshold: 5,
        cooldownPeriod: TimeSpan.FromSeconds(30));

    // Layer 4: Caching wraps circuit breaker
    client = new CachingApiClientDecorator(
        client,
        cache,
        loggerFactory
            .CreateLogger<CachingApiClientDecorator>(),
        cacheDuration: TimeSpan.FromMinutes(10));

    // Layer 5: Logging is the outermost layer
    client = new LoggingApiClientDecorator(
        client,
        loggerFactory
            .CreateLogger<LoggingApiClientDecorator>());

    return client;
});

Why Decorator Order Matters

The order of decorators determines the flow of execution. When a caller invokes GetAsync, the request flows inward from the outermost decorator to the core, and the response flows back out. Here's what happens in sequence:

  1. Logging (outermost) records that a request is starting
  2. Caching checks if it has a stored response -- if yes, returns immediately (skipping everything below)
  3. Circuit Breaker checks if the circuit is open -- if yes, throws immediately
  4. Retry attempts the call, retrying on transient failures
  5. HttpApiClient (core) makes the actual HTTP request

This layering means logging captures cache hits (because logging is outside caching), retries happen inside the circuit breaker (so consecutive retried failures count toward the breaker threshold), and cache misses trigger the full resilience chain.

If you reversed the order and put logging inside caching, you'd never see log entries for cache hits. If you put the circuit breaker inside retry, the breaker would never trip because the retry decorator would absorb all the failures first. Understanding this composition is what separates a working decorator pipeline from a broken one. If you're familiar with the strategy design pattern, you'll appreciate how both patterns rely on composition -- but while strategy swaps entire algorithms, the decorator pattern layers additional behavior around the same algorithm.

Testing the Decorated Pipeline

Testing individual decorators is straightforward because each one only depends on the IApiClient interface and its own specific concern. Here's a set of tests demonstrating how to verify the logging, caching, and retry decorators in isolation, plus an integration test showing the full pipeline:

using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;

using Xunit;

public class LoggingApiClientDecoratorTests
{
    [Fact]
    public async Task GetAsync_LogsRequestAndResponse()
    {
        // Arrange
        var fakeLogger =
            new FakeLogger<LoggingApiClientDecorator>();
        var stubClient = new StubApiClient("response-data");
        var decorator = new LoggingApiClientDecorator(
            stubClient, fakeLogger);

        // Act
        var result = await decorator.GetAsync(
            "https://api.example.com/data");

        // Assert
        Assert.Equal("response-data", result);
        Assert.Contains(fakeLogger.Messages,
            m => m.Contains("starting request"));
        Assert.Contains(fakeLogger.Messages,
            m => m.Contains("completed"));
    }
}

public class CachingApiClientDecoratorTests
{
    [Fact]
    public async Task GetAsync_SecondCall_ReturnsCachedResult()
    {
        // Arrange
        var cache = new MemoryCache(
            new MemoryCacheOptions());
        var fakeLogger =
            new FakeLogger<CachingApiClientDecorator>();
        var callCountClient = new CallCountingApiClient();
        var decorator = new CachingApiClientDecorator(
            callCountClient, cache, fakeLogger);

        // Act
        var first = await decorator.GetAsync(
            "https://api.example.com/users");
        var second = await decorator.GetAsync(
            "https://api.example.com/users");

        // Assert
        Assert.Equal(first, second);
        Assert.Equal(1, callCountClient.GetCallCount);
    }

    [Fact]
    public async Task PostAsync_InvalidatesCache()
    {
        // Arrange
        var cache = new MemoryCache(
            new MemoryCacheOptions());
        var fakeLogger =
            new FakeLogger<CachingApiClientDecorator>();
        var callCountClient = new CallCountingApiClient();
        var decorator = new CachingApiClientDecorator(
            callCountClient, cache, fakeLogger);

        var url = "https://api.example.com/users";
        await decorator.GetAsync(url);

        // Act
        await decorator.PostAsync(url, "{}");
        await decorator.GetAsync(url);

        // Assert
        Assert.Equal(2, callCountClient.GetCallCount);
    }
}

public class RetryApiClientDecoratorTests
{
    [Fact]
    public async Task GetAsync_RetriesOnTransientFailure()
    {
        // Arrange
        var fakeLogger =
            new FakeLogger<RetryApiClientDecorator>();
        var failThenSucceedClient =
            new FailNTimesApiClient(2);
        var decorator = new RetryApiClientDecorator(
            failThenSucceedClient,
            fakeLogger,
            maxRetries: 3,
            baseDelay: TimeSpan.FromMilliseconds(10));

        // Act
        var result = await decorator.GetAsync(
            "https://api.example.com/data");

        // Assert
        Assert.Equal("success", result);
        Assert.Equal(3,
            failThenSucceedClient.AttemptCount);
    }
}

The test helpers keep the tests clean:

using System.Net.Http;

using Microsoft.Extensions.Logging;

public class StubApiClient : IApiClient
{
    private readonly string _response;

    public StubApiClient(string response)
    {
        _response = response;
    }

    public Task<string> GetAsync(string url) =>
        Task.FromResult(_response);

    public Task<string> PostAsync(
        string url, string content) =>
        Task.FromResult(_response);

    public Task<string> PutAsync(
        string url, string content) =>
        Task.FromResult(_response);

    public Task DeleteAsync(string url) =>
        Task.CompletedTask;
}

public class CallCountingApiClient : IApiClient
{
    public int GetCallCount { get; private set; }

    public Task<string> GetAsync(string url)
    {
        GetCallCount++;
        return Task.FromResult(
            $"response-{GetCallCount}");
    }

    public Task<string> PostAsync(
        string url, string content) =>
        Task.FromResult("posted");

    public Task<string> PutAsync(
        string url, string content) =>
        Task.FromResult("updated");

    public Task DeleteAsync(string url) =>
        Task.CompletedTask;
}

public class FailNTimesApiClient : IApiClient
{
    private readonly int _failuresBeforeSuccess;

    public int AttemptCount { get; private set; }

    public FailNTimesApiClient(int failuresBeforeSuccess)
    {
        _failuresBeforeSuccess = failuresBeforeSuccess;
    }

    public Task<string> GetAsync(string url)
    {
        AttemptCount++;

        if (AttemptCount <= _failuresBeforeSuccess)
        {
            throw new HttpRequestException(
                "Simulated transient failure");
        }

        return Task.FromResult("success");
    }

    public Task<string> PostAsync(
        string url, string content) =>
        Task.FromResult("posted");

    public Task<string> PutAsync(
        string url, string content) =>
        Task.FromResult("updated");

    public Task DeleteAsync(string url) =>
        Task.CompletedTask;
}

public class FakeLogger<T> : ILogger<T>
{
    public List<string> Messages { get; } = new();

    public IDisposable? BeginScope<TState>(
        TState state) where TState : notnull => null;

    public bool IsEnabled(LogLevel logLevel) => true;

    public void Log<TState>(
        LogLevel logLevel,
        EventId eventId,
        TState state,
        Exception? exception,
        Func<TState, Exception?, string> formatter)
    {
        Messages.Add(formatter(state, exception));
    }
}

Notice how easy it is to test each decorator independently. The StubApiClient returns a predictable response, CallCountingApiClient tracks how many times the inner client was called, and FailNTimesApiClient simulates transient failures. Because every decorator depends on the IApiClient interface rather than a concrete class, you can swap in any test double you need.

This is a huge advantage over the monolithic approach from the introduction. Testing the original ApiClient class would require mocking HttpClient, ILogger, and IMemoryCache simultaneously, and every test would interact with every concern. With decorators, each test focuses on exactly one behavior. That's a pattern you'll see echoed in other design approaches like the builder design pattern and the pipeline design pattern -- keeping individual pieces small and focused makes the whole system easier to reason about.

Frequently Asked Questions

What's the difference between the decorator pattern and middleware?

Both involve layering behavior around a core operation, but they solve different problems. Middleware is typically part of a framework-provided request pipeline that processes HTTP requests and responses. Decorators are a general-purpose pattern that works with any interface. The decorator pattern gives you compile-time type safety because every decorator implements the same interface, while middleware relies on convention-based delegates.

Can I add or remove decorators at runtime?

Yes, and that's one of the decorator pattern's biggest strengths. Because decorators are composed at object creation time, you can use configuration, feature flags, or environment variables to decide which decorators to include. If you don't need caching in your development environment, simply skip the CachingApiClientDecorator during registration.

How do I handle decorators that need to share state?

Avoid it when possible. Each decorator should be self-contained with its own state. If you genuinely need shared state (like a shared circuit breaker across multiple services), extract that state into a separate injectable service rather than coupling decorators together.

Is there a performance cost to stacking multiple decorators?

Each decorator adds one level of method indirection, which is negligible in virtually all real-world scenarios. The actual HTTP call will dominate the execution time by orders of magnitude. If you're stacking 20+ decorators or operating in a nanosecond-sensitive hot path, measure first -- but for an API client like this example, the overhead is imperceptible.

How does this approach compare to using Polly for resilience?

Polly is a production-grade resilience library with features like jitter, bulkhead isolation, and advanced retry policies. The manual retry and circuit breaker decorators in this article are simpler implementations that illustrate the decorator pattern without adding external dependencies. In a production system, you might use Polly internally within a decorator class, combining the library's resilience features with the pattern's composability.

Should I create an abstract base decorator class?

An abstract base decorator that delegates all methods to the inner client can reduce boilerplate when you have many methods on the interface. However, for interfaces with a small number of methods (like our IApiClient), the explicit delegation in each concrete decorator is clear and keeps each class self-contained. Use an abstract base when the boilerplate becomes a maintenance burden.

How do I decide the order of decorators in the pipeline?

Think about what each decorator should "see." Logging goes outermost so it captures everything, including cache hits. Caching goes outside resilience so cached responses avoid the retry and circuit breaker entirely. Retry goes inside the circuit breaker so that repeated failures from retries count toward the breaker threshold. If you're exploring more about design patterns and how they compose, the ordering principle applies to many layered architectures.

Wrapping Up This Decorator Pattern Real-World Example

This implementation demonstrates the decorator pattern doing what it does best -- turning tangled cross-cutting concerns into clean, composable layers. We started with a monolithic HTTP client that mixed logging, caching, retry, and circuit breaker logic into a single class. We ended with five focused classes, each handling exactly one concern, that snap together like building blocks.

The decorator pattern isn't about adding complexity for the sake of architecture. It's about putting each responsibility in a place where it can be understood, tested, and modified independently. When you need to change the retry policy, you edit one class. When you need to disable caching, you remove one registration line. When a new concern like rate limiting or metrics collection comes up, you write one new decorator and slot it into the pipeline.

Take this implementation, adapt the IApiClient interface to your domain, and start layering your own decorators. Once you see how cleanly cross-cutting concerns compose with this pattern, you'll reach for it every time you're tempted to add "just one more if-statement" to an existing service class.

When to Use Decorator Pattern in C#: Decision Guide with Examples

Learn when to use decorator pattern in C# with a practical decision guide, code examples, and real scenarios for choosing the right approach.

Decorator Pattern - How To Master It In C# Using Autofac

Want to know how the decorator pattern works? Let's check out an Autofac example in C# where we can get the decorator pattern with Autofac working!

Decorator Design Pattern in C#: Complete Guide with Examples

Master the decorator design pattern in C# with practical code examples, best practices, and real-world use cases for flexible object extension.

An error has occurred. This application may no longer respond until reloaded. Reload