Back to blog

Decorator Design Pattern - Adding Telemetry to HttpClient

January 12, 20258 min read

Master the Decorator Pattern to enhance HttpClient functionality with telemetry, logging, and caching capabilities while maintaining clean, maintainable code architecture in ASP.NET Core.

Development Series — 23 articles
  1. Mastering Git Repository Organization
  2. CancellationToken for Async Programming
  3. Git Flow Rethink: When Process Stops Paying Rent
  4. Understanding System Cache: A Comprehensive Guide
  5. Guide to Redis Local Instance Setup
  6. Fire and Forget for Enhanced Performance
  7. Building Resilient .NET Applications with Polly
  8. The Singleton Advantage: Managing Configurations in .NET
  9. Troubleshooting and Rebuilding My JS-Dev-Env Project
  10. Decorator Design Pattern - Adding Telemetry to HttpClient
  11. Generate Wiki Documentation from Your Code Repository
  12. TaskListProcessor - Enterprise Async Orchestration for .NET
  13. Architecting Agentic Services in .NET 9: Semantic Kernel
  14. NuGet Packages: Benefits and Challenges
  15. My Journey as a NuGet Gallery Developer and Educator
  16. Harnessing the Power of Caching in ASP.NET
  17. The Building of React-native-web-start
  18. TailwindSpark: Ignite Your Web Development
  19. Creating a PHP Website with ChatGPT
  20. Evolving PHP Development
  21. Modernizing Client Libraries in a .NET 4.8 Framework Application
  22. Building Git Spark: My First npm Package Journey
  23. Dave's Top Ten: Git Stats You Should Never Track

Introduction to the Decorator Pattern

On a recent project, I needed to layer telemetry, logging, and caching onto an HttpClient without modifying the core service. Inheritance felt brittle — every new capability meant another subclass, and I'd end up with a tangled hierarchy that nobody wanted to touch. Middleware didn't give me object-level control. The decorator pattern turned out to be the answer, and the constraints I hit along the way taught me when it's genuinely the right call.

The decorator pattern provides a flexible alternative to subclassing by wrapping objects in decorator objects that add new functionality while preserving the original interface. Each decorator wraps an instance of the base interface and provides its own enhanced implementation — meaning you can stack capabilities independently without touching the underlying class.

I've watched teams try to add telemetry by modifying the original HttpClient class directly, and they end up with a god object that's impossible to test in isolation. The decorator pattern sidesteps that entirely. It's not magic — you pay a small runtime cost for each wrapper, and the call chain can become hard to trace if you stack too many layers. Middleware in ASP.NET Core often solves cross-cutting concerns cheaper, but decorators give you object-level control that middleware can't match.

A live demonstration is hosted at AsyncSpark on WebSpark.

Interface Design

In practice, this approach shines when you need independent, stackable behavior. I structured this around two interfaces to keep the decorator and the service cleanly separated. The common mistake I've seen teams make is collapsing everything into one concrete class and then wondering why it's untestable — keeping the interface boundary explicit is what makes the whole pattern work.

IHttpGetCallService

Defines methods for making HTTP requests and returning strongly-typed response data:

public interface IHttpGetCallService
{
    Task<HttpGetCallResults<T>> GetAsync<T>(
        HttpGetCallResults<T> callResults
    );
}

IHttpGetCallResults

Defines properties for HTTP call response data including timing, errors, and results:

public interface IHttpGetCallResults<T>
{
    DateTime? CompletionDate { get; set; }
    long ElapsedMilliseconds { get; set; }
    string? ErrorMessage { get; set; }
    int Id { get; set; }
    int Iteration { get; set; }
    string RequestPath { get; set; }
    T? ResponseResults { get; set; }
    int Retries { get; set; }
}

The HttpGetCallResults<T> generic implementation provides compile-time type checking, improved IntelliSense, and strongly-typed response handling, making code cleaner and more maintainable.

Service Implementations

The decorator pattern involves two implementation types: a concrete implementation and one or more decorator implementations that wrap it.

Concrete Implementation

The concrete implementation provides core functionality — the original object being decorated — with basic HTTP request capabilities:

public async Task<HttpGetCallResults<T>> GetAsync<T>(
    HttpGetCallResults<T> callResults)
{
    if (callResults == null)
    {
        throw new ArgumentNullException(nameof(callResults));
    }

    try
    {
        using var httpClient = _clientFactory.CreateClient();
        var request = new HttpRequestMessage(
            HttpMethod.Get, callResults.RequestPath);
        var response = await httpClient.SendAsync(request);
        response.EnsureSuccessStatusCode();

        var content = await response.Content.ReadAsStringAsync();
        callResults.ResponseResults =
            JsonSerializer.Deserialize<T>(content);
    }
    catch (Exception ex)
    {
        _logger.LogCritical(ex.Message);
        callResults.ErrorMessage = ex.Message;
    }

    return callResults;
}

Key features of the concrete implementation:

  • Error handling with null checks
  • JSON deserialization with fallback
  • IHttpClientFactory integration
  • Comprehensive logging

Decorator Implementation

The decorator wraps the concrete implementation, adding telemetry functionality while maintaining the same interface:

public async Task<HttpGetCallResults<T>> GetAsync<T>(
    HttpGetCallResults<T> callResults)
{
    Stopwatch sw = new();
    sw.Start();

    var response = new HttpGetCallResults<T>(callResults);

    try
    {
        response = await _service.GetAsync<T>(callResults);
    }
    catch (Exception ex)
    {
        _logger.LogCritical("Telemetry:GetAsync:Exception", ex.Message);
    }

    sw.Stop();
    response.ElapsedMilliseconds = sw.ElapsedMilliseconds;
    response.CompletionDate = DateTime.Now;

    return response;
}

Telemetry features added by the decorator:

  • Performance timing with Stopwatch
  • Completion timestamp tracking
  • Exception handling and logging
  • Transparent call forwarding to the wrapped service

What I've found is that this pattern scales cleanly when each decorator owns a single concern. The telemetry wrapper above has no knowledge of caching or retry logic — it just measures and forwards. That independence is what makes the layers composable without creating hidden dependencies between them.

Integration & Configuration

Configure the decorator pattern in Program.cs using dependency injection to seamlessly integrate telemetry capabilities.

Dependency Injection Setup

// Configure HttpClient with timeout and headers
builder.Services.AddHttpClient("web.makeboldspark.com",
    client =>
{
    client.Timeout = TimeSpan.FromMilliseconds(1500);
    client.DefaultRequestHeaders.Add("Accept", "application/json");
    client.DefaultRequestHeaders.Add("User-Agent",
        "web.makeboldspark.com");
    client.DefaultRequestHeaders.Add("X-Request-ID",
        Guid.NewGuid().ToString());
});

// Register decorator pattern with telemetry wrapper
builder.Services.AddSingleton<IHttpGetCallService>(
    serviceProvider =>
{
    var logger = serviceProvider
        .GetRequiredService<ILogger<HttpGetCallService>>();
    var telemetryLogger = serviceProvider
        .GetRequiredService<ILogger<HttpGetCallServiceTelemetry>>();
    var httpClientFactory = serviceProvider
        .GetRequiredService<IHttpClientFactory>();

    IHttpGetCallService baseService =
        new HttpGetCallService(logger, httpClientFactory);
    IHttpGetCallService telemetryService =
        new HttpGetCallServiceTelemetry(telemetryLogger, baseService);

    return telemetryService;
});

Razor Page Implementation

Example using the decorated service in a Razor Page to fetch jokes from an external API:

public class JokeModel : PageModel
{
    private readonly ILogger<JokeModel> _logger;
    private readonly IHttpGetCallService _service;

    public HttpGetCallResults<Joke> JokeResult { get; set; }
    public Joke TheJoke { get; set; } = new Joke();

    public JokeModel(ILogger<JokeModel> logger,
        IHttpGetCallService getCallService)
    {
        _logger = logger;
        _service = getCallService;
    }

    public async Task OnGet(CancellationToken ct = default)
    {
        JokeResult = new HttpGetCallResults<Joke>
        {
            RequestPath = "https://v2.jokeapi.dev/joke/Any?safe-mode"
        };

        JokeResult = await _service.GetAsync(JokeResult, ct);

        TheJoke = JokeResult?.ResponseResults ?? new Joke
        {
            error = true
        };
    }
}

Additional Use Cases & Extensions

The decorator pattern's flexibility allows for multiple enhancements to HttpClient functionality beyond telemetry.

Logging Decorator

Automatically log all HTTP requests and responses for debugging and auditing:

  • Request/response logging
  • Header inspection
  • Status code tracking
  • Payload monitoring

Caching Decorator

Improve performance by caching responses and serving from cache when possible:

  • Response caching
  • Cache invalidation
  • TTL management
  • Memory optimization

Retry Decorator

Automatically retry failed requests using Polly library with configurable policies:

  • Exponential backoff
  • Circuit breaker
  • Timeout handling
  • Policy configuration

These decorators can be combined and layered to create a powerful, feature-rich HTTP client with telemetry, logging, caching, and retry capabilities all working together. In my experience, the order of stacking matters — I put the telemetry decorator outermost so it captures the total elapsed time including retries, not just the time for a single attempt.

WebSpark.HttpClientUtility NuGet Package

The decorator pattern work described here evolved into a published NuGet package: WebSpark.HttpClientUtility. See the project page for full details.

The package is a drop-in HttpClient wrapper for .NET 8–10 that bundles the decorator layers demonstrated here — telemetry, retry, caching, and logging — into a single library. One registration call replaces 50+ lines of manual DI setup.

Installation

dotnet add package WebSpark.HttpClientUtility

Single-Line Registration

builder.Services.AddHttpClientUtility();

Key Features

  • Intuitive Request/Response Model: Type-safe interactions with HttpGetCallResults<T>
  • Built-in Resilience: Integrated Polly retry and circuit breaker patterns
  • Response Caching: Configurable in-memory caching for API responses
  • Correlation Tracking: Automatic correlation ID generation for distributed tracing
  • Structured Logging: Rich contextual logging throughout the request lifecycle
  • OpenTelemetry Support: Full observability integration for monitoring
  • Trimming & AOT Ready: Compatible with Native AOT and IL trimming
  • Source Link: Full debugging support into library source

Package Architecture

Starting with v2.0, the library is split into two focused packages:

PackageSizePurpose
WebSpark.HttpClientUtility163 KBCore HTTP utilities, telemetry, resilience, caching
WebSpark.HttpClientUtility.Crawler75 KBWeb crawling, sitemap generation, robots.txt compliance

The crawler package extends the core with website crawling (depth control), XML sitemap generation, HTML parsing via HtmlAgilityPack, real-time progress via SignalR, and CSV export.

Supported Frameworks

Targets .NET 8.0, 9.0, and 10.0 LTS. The package ships with 237+ unit tests — 711 test runs across all three frameworks — with a 100% pass rate. Zero-warning builds and baseline package validation prevent breaking changes.

This package is the direct result of applying the decorator pattern at scale. Each capability — telemetry, retry, caching — is its own composable decorator layer, exactly as described here.

Source Code

Complete source code and working examples are available on GitHub:

Conclusion

Pattern Benefits

  • Dynamic behavior addition without subclassing
  • Flexible object composition
  • Single responsibility principle adherence
  • Open/closed principle adherence

Implementation Advantages

  • Clean separation of concerns
  • Easy to test and maintain
  • Interchangeable, composable components
  • Enhanced functionality without modifying existing code
  • Scalable architecture

By implementing the decorator pattern with HttpClient, I demonstrated how to add telemetry capabilities without modifying core functionality, create reusable composable components, maintain clean separation between concerns, and build applications that can grow a new capability by adding a wrapper rather than reopening a class. The trade-off is worth it when the behaviors are genuinely independent — when they're not, the layering creates more confusion than it solves, and that's the honest limit of this approach.

Further Reading

Explore More