Decorator Design Pattern - Adding Telemetry to HttpClient
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
- Mastering Git Repository Organization
- CancellationToken for Async Programming
- Git Flow Rethink: When Process Stops Paying Rent
- Understanding System Cache: A Comprehensive Guide
- Guide to Redis Local Instance Setup
- Fire and Forget for Enhanced Performance
- Building Resilient .NET Applications with Polly
- The Singleton Advantage: Managing Configurations in .NET
- Troubleshooting and Rebuilding My JS-Dev-Env Project
- Decorator Design Pattern - Adding Telemetry to HttpClient
- Generate Wiki Documentation from Your Code Repository
- TaskListProcessor - Enterprise Async Orchestration for .NET
- Architecting Agentic Services in .NET 9: Semantic Kernel
- NuGet Packages: Benefits and Challenges
- My Journey as a NuGet Gallery Developer and Educator
- Harnessing the Power of Caching in ASP.NET
- The Building of React-native-web-start
- TailwindSpark: Ignite Your Web Development
- Creating a PHP Website with ChatGPT
- Evolving PHP Development
- Modernizing Client Libraries in a .NET 4.8 Framework Application
- Building Git Spark: My First npm Package Journey
- 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
IHttpClientFactoryintegration- 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.HttpClientUtilitySingle-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:
| Package | Size | Purpose |
|---|---|---|
WebSpark.HttpClientUtility | 163 KB | Core HTTP utilities, telemetry, resilience, caching |
WebSpark.HttpClientUtility.Crawler | 75 KB | Web 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
- My Journey as a NuGet Gallery Developer and Educator -- From Creation to Education in the NuGet Ecosystem
- TaskListProcessor - Enterprise Async Orchestration for .NET -- Enterprise Async Orchestration for .NET
- Harnessing the Power of Caching in ASP.NET -- Enhancing ASP.NET Performance with MemoryCacheManager
- Building Resilient .NET Applications with Polly -- Enhance Application Reliability with Polly and HttpClient
- The Singleton Advantage: Managing Configurations in .NET -- Enhancing Configuration Management with Singleton Pattern


