Decorator pattern, in short
The decorator pattern is a structural design pattern that lets you add behavior to an object at runtime by wrapping it with another object that has the same interface. The wrapper forwards calls to the inner object, then adds extra work before or after the call. You keep the original contract, you can stack multiple decorators, and you avoid subclass explosion by preferring composition over inheritance.
Key points
- Intent, attach additional responsibilities to an object dynamically
- How, wrap an object that implements the same interface and forward
calls
- Good for cross cutting concerns like logging, retry, caching,
metrics, authorization
- Benefits, core code stays simple, behaviors are pluggable and
testable, decorators compose
- Trade offs, more indirection and wiring, the order of decorators
matters
Why decorate in ASP.NET Core
Decoration lets you add cross cutting behavior around a service without touching its core code, for example retry, logging, metrics, caching. It keeps concerns separated and easy to test.
Packages
dotnet add package Scrutor
dotnet add package Polly
Core service
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text.Json;
public interface IInvoicesClient
{
Task<PagedResultDtoOfInvoice> GetInvoicesAsync(
string filter, string orderBy, string include,
int? pageIndex, int? pageSize, bool? transactionCurrency, bool? skipRounding,
CancellationToken ct = default);
}
public sealed class InvoicesClient : IInvoicesClient
{
private readonly HttpClient _http;
private readonly string _baseUrl;
private readonly Func<string> _getToken;
private static readonly JsonSerializerOptions _json = new() { PropertyNameCaseInsensitive = true };
public InvoicesClient(HttpClient http, string baseUrl, Func<string> getToken)
{
_http = http;
_baseUrl = baseUrl.TrimEnd('/');
_getToken = getToken;
}
public async Task<PagedResultDtoOfInvoice> GetInvoicesAsync(
string filter, string orderBy, string include,
int? pageIndex, int? pageSize, bool? transactionCurrency, bool? skipRounding,
CancellationToken ct = default)
{
var url = BuildUrl(_baseUrl, filter, orderBy, include, pageIndex, pageSize, transactionCurrency, skipRounding);
using var req = new HttpRequestMessage(HttpMethod.Get, url)
{
#if NET5_0_OR_GREATER
VersionPolicy = HttpVersionPolicy.RequestVersionOrHigher
#endif
};
var token = _getToken?.Invoke();
if (!string.IsNullOrEmpty(token))
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
using var resp = await _http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead, ct).ConfigureAwait(false);
resp.EnsureSuccessStatusCode();
await using var stream = await resp.Content.ReadAsStreamAsync(ct).ConfigureAwait(false);
return await JsonSerializer.DeserializeAsync<PagedResultDtoOfInvoice>(stream, _json, ct).ConfigureAwait(false)
?? new PagedResultDtoOfInvoice();
}
private static string BuildUrl(
string baseUrl, string filter, string orderBy, string include,
int? pageIndex, int? pageSize, bool? transactionCurrency, bool? skipRounding)
{
var q = System.Web.HttpUtility.ParseQueryString(string.Empty);
if (!string.IsNullOrWhiteSpace(filter)) q["filter"] = filter;
if (!string.IsNullOrWhiteSpace(orderBy)) q["orderBy"] = orderBy;
if (!string.IsNullOrWhiteSpace(include)) q["include"] = include;
if (pageIndex.HasValue) q["pageIndex"] = pageIndex.Value.ToString();
if (pageSize.HasValue) q["pageSize"] = pageSize.Value.ToString();
if (transactionCurrency.HasValue) q["transactionCurrency"] = transactionCurrency.Value ? "true" : "false";
if (skipRounding.HasValue) q["skipRounding"] = skipRounding.Value ? "true" : "false";
return $"{baseUrl}/invoices?{q}";
}
}
Decorators
using Polly;
using System.Net.Sockets;
public sealed class RetryInvoicesClientDecorator : IInvoicesClient
{
private readonly IInvoicesClient _inner;
private readonly IAsyncPolicy _retry;
public RetryInvoicesClientDecorator(IInvoicesClient inner)
{
_inner = inner;
_retry = Policy
.Handle<HttpRequestException>()
.Or<IOException>()
.Or<SocketException>()
.Or<TaskCanceledException>(ex => !ex.CancellationToken.IsCancellationRequested)
.WaitAndRetryAsync(3,
attempt => TimeSpan.FromSeconds(Math.Pow(2, attempt)) +
TimeSpan.FromMilliseconds(Random.Shared.Next(0, 750)));
}
public Task<PagedResultDtoOfInvoice> GetInvoicesAsync(
string filter, string orderBy, string include,
int? pageIndex, int? pageSize, bool? transactionCurrency, bool? skipRounding,
CancellationToken ct = default)
=>
_retry.ExecuteAsync(t =>
_inner.GetInvoicesAsync(filter, orderBy, include, pageIndex, pageSize, transactionCurrency, skipRounding, t),
ct);
}
Logging decorator
public sealed class LoggingInvoicesClientDecorator : IInvoicesClient
{
private readonly IInvoicesClient _inner;
private readonly ILogger<LoggingInvoicesClientDecorator> _log;
public LoggingInvoicesClientDecorator(IInvoicesClient inner, ILogger<LoggingInvoicesClientDecorator> log)
{ _inner = inner; _log = log; }
public async Task<PagedResultDtoOfInvoice> GetInvoicesAsync(
string filter, string orderBy, string include,
int? pageIndex, int? pageSize, bool? transactionCurrency, bool? skipRounding,
CancellationToken ct = default)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
try
{
_log.LogInformation("Invoices request pageIndex={PageIndex} pageSize={PageSize}", pageIndex, pageSize);
var res = await _inner.GetInvoicesAsync(filter, orderBy, include, pageIndex, pageSize, transactionCurrency, skipRounding, ct);
_log.LogInformation("Invoices response count={Count} in {Elapsed} ms", res?.Data?.Count ?? 0, sw.ElapsedMilliseconds);
return res;
}
catch (Exception ex)
{
_log.LogError(ex, "Invoices failed in {Elapsed} ms", sw.ElapsedMilliseconds);
throw;
}
}
}
Registration with Scrutor
using Scrutor;
using System.Net;
builder.Services.AddHttpClient("Invoices", c =>
{
c.Timeout = Timeout.InfiniteTimeSpan;
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
})
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate,
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
MaxConnectionsPerServer = 8
});
builder.Services.AddTransient<IInvoicesClient>(sp =>
{
var http = sp.GetRequiredService<IHttpClientFactory>().CreateClient("Invoices");
var cfg = sp.GetRequiredService<IConfiguration>();
var baseUrl = cfg["Erp:BaseUrl"] ?? "";
var tokenProvider = sp.GetRequiredService<ITokenProvider>();
return new InvoicesClient(http, baseUrl, () => tokenProvider.GetToken());
});
builder.Services.Decorate<IInvoicesClient, LoggingInvoicesClientDecorator>();
builder.Services.Decorate<IInvoicesClient, RetryInvoicesClientDecorator>();
Native DI without Scrutor
builder.Services.AddHttpClient("Invoices", c =>
{
c.Timeout = Timeout.InfiniteTimeSpan;
c.DefaultRequestHeaders.Accept.ParseAdd("application/json");
});
builder.Services.AddTransient<IInvoicesClient>(sp =>
{
var http = sp.GetRequiredService<IHttpClientFactory>().CreateClient("Invoices");
var cfg = sp.GetRequiredService<IConfiguration>();
var baseUrl = cfg["Erp:BaseUrl"] ?? "";
var tokenProvider = sp.GetRequiredService<ITokenProvider>();
IInvoicesClient svc = new InvoicesClient(http, baseUrl, () => tokenProvider.GetToken());
svc = new LoggingInvoicesClientDecorator(svc, sp.GetRequiredService<ILogger<LoggingInvoicesClientDecorator>>());
svc = new RetryInvoicesClientDecorator(svc);
return svc;
});
DelegatingHandler for transport concerns
Use handlers for wire level logic, keep decorators for service level logic.
public sealed class LoggingHandler : DelegatingHandler
{
private readonly ILogger<LoggingHandler> _log;
public LoggingHandler(ILogger<LoggingHandler> log) => _log = log;
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var response = await base.SendAsync(request, ct);
_log.LogInformation("{Method} {Uri} -> {Status} in {Elapsed} ms",
request.Method, request.RequestUri, (int)response.StatusCode, sw.ElapsedMilliseconds);
return response;
}
}
builder.Services.AddTransient<LoggingHandler>();
builder.Services.AddHttpClient("Invoices", c => { /* defaults above */ })
.AddHttpMessageHandler<LoggingHandler>();
Best practices checklist
- One retry layer only.
- Per call timeout with CancelAfter.
- ResponseHeadersRead for large payloads.
- Small page size for large ranges.
- Keep HttpClient immutable after registration.
- Put auth headers on the request message.
References
Polly, official docs and GitHub.
Scrutor, GitHub and NuGet.
Microsoft docs, HttpClientFactory with resilience patterns.
Source Code