A fluent interface for building decorators
$ dotnet add package DecoratRIntuitive .NET library for implementing the Decorator pattern with Microsoft's Dependency Injection container. DecoratR provides a fluent API to chain decorators around your services, enabling cross-cutting concerns like logging, caching, retry logic, and more.
dotnet add package DecoratR
using DecoratR;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
// Basic decoration
services.Decorate<IUserService>()
.With<LoggingDecorator>()
.Then<CacheDecorator>()
.Then<UserService>()
.Apply();
var provider = services.BuildServiceProvider();
var userService = provider.GetService<IUserService>();
// Result: LoggingDecorator -> CacheDecorator -> UserService
Decorators are applied in the order they are defined. The last decorator added should be your base implementation:
services.Decorate<IService>()
.With<FirstDecorator>() // Outermost decorator
.Then<SecondDecorator>() // Middle decorator
.Then<BaseService>() // Base implementation (innermost)
.Apply();
Decorators (except the base implementation) must have a constructor that accepts the service type as the first parameter:
public class LoggingDecorator(IService inner) : IService
{
public string Execute() => $"Log({inner.Execute()})";
}
using System.Collections.Concurrent;
// Service interface
public interface IOrderService
{
Task<Order> GetOrderAsync(int orderId);
}
// Logging decorator
public class LoggingDecorator(IOrderService inner) : IOrderService
{
public async Task<Order> GetOrderAsync(int orderId)
{
Console.WriteLine($"Getting order {orderId}");
return await inner.GetOrderAsync(orderId);
}
}
// Cache decorator
public class CacheDecorator(IOrderService inner) : IOrderService
{
private static readonly ConcurrentDictionary<int, Order> _cache = new();
public async Task<Order> GetOrderAsync(int orderId)
{
if (_cache.TryGetValue(orderId, out var cachedOrder))
return cachedOrder;
var order = await inner.GetOrderAsync(orderId);
_cache.TryAdd(orderId, order);
return order;
}
}
// Configuration
services.Decorate<IOrderService>()
.With<LoggingDecorator>()
.Then<CacheDecorator>()
.Then<OrderService>()
.AsScoped()
.Apply();
Apply decorators based on runtime conditions:
services.Decorate<IOrderService>()
.With<LoggingDecorator>()
.ThenIf<RetryDecorator>(env.IsDevelopment()) // Only in development
.ThenIf<CacheDecorator>(enableCaching) // Based on configuration
.Then<OrderService>()
.Apply();
Note: Keyed services require .NET 8.0 or later. This feature is not available when targeting .NET 6.0 or 7.0.
DecoratR fully supports .NET 8+ keyed services, allowing you to create different decorator chains for the same service type:
// Different configurations for different contexts
services.Decorate<IOrderService>("internal")
.With<LoggingDecorator>()
.Then<OrderService>()
.Apply();
services.Decorate<IOrderService>("external")
.With<LoggingDecorator>()
.Then<RateLimitingDecorator>()
.Then<SecurityDecorator>()
.Then<OrderService>()
.Apply();
services.Decorate<IOrderService>("cached")
.With<CacheDecorator>()
.Then<OrderService>()
.AsSingleton()
.Apply();
// Usage
var internalService = provider.GetRequiredKeyedService<IOrderService>("internal");
var externalService = provider.GetRequiredKeyedService<IOrderService>("external");
var cachedService = provider.GetRequiredKeyedService<IOrderService>("cached");
You can use any object as a key, including anonymous objects:
var productionKey = new { Environment = "Production", Version = "v2" };
services.Decorate<IOrderService>(productionKey)
.With<AuditDecorator>()
.Then<SecurityDecorator>()
.Then<OrderService>()
.Apply();
// Usage
var service = provider.GetRequiredKeyedService<IOrderService>(productionKey);
For decorators that require complex initialization or dependencies not easily handled by the DI container:
services.AddSingleton<IMetrics, MetricsService>();
services.AddSingleton<IConfiguration, ConfigurationService>();
services.Decorate<IOrderService>()
.With((serviceProvider, inner) =>
new MetricsDecorator(
inner,
serviceProvider.GetRequiredService<IMetrics>(),
serviceProvider.GetRequiredService<IConfiguration>().GetValue<string>("MetricsPrefix")))
.Then<OrderService>()
.Apply();
// Conditional factory with complex logic
services.Decorate<IOrderService>()
.WithIf(enableAdvancedMetrics, (sp, inner) =>
{
var config = sp.GetRequiredService<IConfiguration>();
var metrics = sp.GetRequiredService<IMetrics>();
var logger = sp.GetRequiredService<ILogger<AdvancedMetricsDecorator>>();
return new AdvancedMetricsDecorator(inner, metrics, config, logger);
})
.Then<OrderService>()
.Apply();
// Factory for base implementation
services.Decorate<IOrderService>()
.With<LoggingDecorator>()
.Then((serviceProvider, _) =>
{
var connectionString = serviceProvider.GetRequiredService<IConfiguration>()
.GetConnectionString("DefaultConnection");
return new DatabaseOrderService(connectionString);
})
.Apply();
DecoratR supports generic decorators with multiple type parameters:
// Generic decorator with one type parameter
public class CacheDecorator<T>(IService<T> inner) : IService<T>
{
private static readonly Dictionary<string, T> _cache = new();
public async Task<T> GetAsync(string key)
{
if (_cache.TryGetValue(key, out var cachedValue))
return cachedValue;
var value = await inner.GetAsync(key);
_cache[key] = value;
return value;
}
}
// Multiple generic parameters
public class TransformDecorator<TInput, TOutput>(ITransformService<TInput, TOutput> inner)
: ITransformService<TInput, TOutput>
{
public async Task<TOutput> TransformAsync(TInput input)
{
Console.WriteLine($"Transforming {typeof(TInput).Name} to {typeof(TOutput).Name}");
return await inner.TransformAsync(input);
}
}
// Configuration
services.Decorate<IService<string>>()
.With<CacheDecorator<string>>()
.Then<StringService>()
.Apply();
services.Decorate<ITransformService<User, UserDto>>()
.With<TransformDecorator<User, UserDto>>()
.Then<UserTransformService>()
.Apply();
Control the lifetime of your decorated services:
// Singleton
services.Decorate<IOrderService>()
.With<CacheDecorator>()
.Then<OrderService>()
.AsSingleton()
.Apply();
// Scoped
services.Decorate<IOrderService>()
.With<LoggingDecorator>()
.Then<OrderService>()
.AsScoped()
.Apply();
// Transient (default)
services.Decorate<IOrderService>()
.With<RetryDecorator>()
.Then<OrderService>()
.AsTransient() // Optional, as it's the default
.Apply();
// Custom lifetime
services.Decorate<IOrderService>()
.With<MetricsDecorator>()
.Then<OrderService>()
.WithLifetime(ServiceLifetime.Scoped)
.Apply();
Consider the logical flow of your decorators:
// Good: Logical order from outside to inside
services.Decorate<IService>()
.With<SecurityDecorator>() // Authentication/Authorization first
.Then<RateLimitingDecorator>() // Rate limiting after security
.Then<LoggingDecorator>() // Logging after rate limiting
.Then<MetricsDecorator>() // Metrics collection
.Then<CacheDecorator>() // Cache closest to data source
.Then<BaseService>() // Base implementation
.Apply();
Implement proper error handling in decorators:
public class ErrorHandlingDecorator(IService inner, ILogger<ErrorHandlingDecorator> logger) : IService
{
public async Task<Result> ExecuteAsync(Request request)
{
try
{
return await inner.ExecuteAsync(request);
}
catch (Exception ex)
{
logger.LogError(ex, "Error executing request {RequestId}", request.Id);
throw;
}
}
}
Be mindful of decorator overhead:
// Use conditional decoration for expensive operations
var enableDetailedLogging = config.GetValue<bool>("Logging:Detailed");
services.Decorate<IService>()
.WithIf<DetailedLoggingDecorator>(enableDetailedLogging)
.Then<BaseService>()
.Apply();
Decorators are easy to test in isolation:
[Test]
public void LoggingDecorator_ShouldLogExecution()
{
// Arrange
var mockInner = new Mock<IService>();
var mockLogger = new Mock<ILogger<LoggingDecorator>>();
var decorator = new LoggingDecorator(mockInner.Object, mockLogger.Object);
// Act
decorator.Execute();
// Assert
mockLogger.Verify(x => x.LogInformation(It.IsAny<string>()), Times.Once);
mockInner.Verify(x => x.Execute(), Times.Once);
}
Decorate<TService>() - Begin decoration for a service typeDecorate<TService>(object serviceKey) - Begin decoration for a keyed serviceThen<TDecorator>() - Add a decorator to the chainWith<TDecorator>() - Alias for Then<TDecorator>()Then(Func<IServiceProvider, TService, TService> factory) - Add decorator via factoryThenIf<TDecorator>(bool condition) - Conditionally add decoratorWithIf<TDecorator>(bool condition) - Alias for ThenIf<TDecorator>(bool condition)ThenIf(bool condition, Func<IServiceProvider, TService, TService> factory) - Conditionally add decorator via factoryWithLifetime(ServiceLifetime lifetime) - Set service lifetimeAsSingleton() - Set lifetime to SingletonAsScoped() - Set lifetime to ScopedAsTransient() - Set lifetime to TransientApply() - Apply the decoration configurationNote: Keyed services are only available in .NET 8.0 or later. If you're using .NET 6.0 or 7.0, you can only use the regular (non-keyed) decoration features.
Contributions are welcome! Please feel free to submit issues and pull requests on the GitHub repository.
This project is licensed under the MIT License. See the LICENSE file for details.