When Inheritance Isn't Enough
It's tempting to pile features into subclasses. It works until you need a class that combines logging, caching, and retry logic. Suddenly you're staring at LoggingCachingRetryService, and adding validation means creating four more subclasses to cover every combination.
The Decorator pattern solves this by wrapping objects with additional behavior instead of inheriting it. Each decorator adds one responsibility and passes everything else through to the wrapped object. You compose features at runtime by stacking decorators in the order you need.
You'll build a notification system that starts simple and grows to handle logging, rate limiting, and fallback delivery without changing the core implementation. By the end, you'll chain decorators safely and know when composition beats inheritance.
Building Your First Decorator
A decorator implements the same interface as the object it wraps. When a method is called, the decorator can add behavior before, after, or around the call to the wrapped object. This keeps the original object unchanged while extending what it does.
Start with a simple notification service that sends messages. The base implementation just delivers the message without any extra behavior.
namespace DecoratorDemo;
public interface INotificationService
{
void Send(string recipient, string message);
}
public class EmailNotificationService : INotificationService
{
public void Send(string recipient, string message)
{
Console.WriteLine($"Sending email to {recipient}: {message}");
// Actual email sending logic would go here
}
}
This base service does one thing: it sends email notifications. Now you can wrap it with decorators that add logging, validation, or any other cross-cutting concern without touching this code.
Adding Logging with a Decorator
The first decorator wraps the notification service and logs every message sent. It implements the same interface and holds a reference to another INotificationService. When Send is called, it logs first, then delegates to the wrapped service.
namespace DecoratorDemo;
public class LoggingNotificationDecorator : INotificationService
{
private readonly INotificationService _inner;
private readonly ILogger _logger;
public LoggingNotificationDecorator(
INotificationService inner,
ILogger logger)
{
_inner = inner;
_logger = logger;
}
public void Send(string recipient, string message)
{
_logger.LogInformation(
"Sending notification to {Recipient} with message length {Length}",
recipient,
message.Length);
_inner.Send(recipient, message);
_logger.LogInformation(
"Notification sent successfully to {Recipient}",
recipient);
}
}
Notice how the decorator doesn't know or care what the inner service does. It could be the email service, or another decorator, or any implementation. This is composition in action: each piece does one job and delegates the rest.
Stacking Decorators for Rate Limiting
Add another decorator to limit how often messages can be sent to the same recipient. This prevents spam and respects API rate limits without modifying existing code.
namespace DecoratorDemo;
public class RateLimitingDecorator : INotificationService
{
private readonly INotificationService _inner;
private readonly Dictionary _lastSent = new();
private readonly TimeSpan _minimumInterval;
public RateLimitingDecorator(
INotificationService inner,
TimeSpan minimumInterval)
{
_inner = inner;
_minimumInterval = minimumInterval;
}
public void Send(string recipient, string message)
{
if (_lastSent.TryGetValue(recipient, out var lastTime))
{
var elapsed = DateTime.UtcNow - lastTime;
if (elapsed < _minimumInterval)
{
throw new InvalidOperationException(
$"Rate limit exceeded. Wait {(_minimumInterval - elapsed).TotalSeconds:F1}s");
}
}
_inner.Send(recipient, message);
_lastSent[recipient] = DateTime.UtcNow;
}
}
This decorator checks timing before delegating to the inner service. You can now stack logging and rate limiting by wrapping one decorator inside another. The order you build the chain determines the order behavior executes.
Composing Multiple Decorators
You build the decorator chain from the inside out. Start with the core service, wrap it with the first decorator, then wrap that result with the next decorator. Each layer adds its behavior around all the inner layers.
using DecoratorDemo;
using Microsoft.Extensions.Logging;
var loggerFactory = LoggerFactory.Create(builder =>
builder.AddConsole());
var logger = loggerFactory.CreateLogger();
// Build the decorator chain
INotificationService service = new EmailNotificationService();
service = new RateLimitingDecorator(service, TimeSpan.FromSeconds(5));
service = new LoggingNotificationDecorator(service, logger);
// Use the fully decorated service
service.Send("user@example.com", "Your order has shipped");
// Try sending again immediately
try
{
service.Send("user@example.com", "Follow-up message");
}
catch (InvalidOperationException ex)
{
Console.WriteLine($"Caught expected exception: {ex.Message}");
}
When you call Send, the logging decorator runs first, then the rate limiter checks timing, and finally the email service sends the message. Swap the order and you'll log before or after rate limiting depending on your needs.
Common Pitfalls
Forgetting to delegate: If a decorator doesn't call the inner service, the chain breaks and the original functionality never runs. Always ensure your decorator forwards the call unless you intentionally want to short-circuit the chain for specific cases like caching or validation failures.
Interface bloat: When your interface has many methods, each decorator must implement all of them even if it only modifies one. Keep interfaces focused or use abstract base decorators that provide pass-through implementations for methods you don't need to customize.
Decorator ordering confusion: The order you wrap decorators matters deeply. Logging outside caching means you'll log cache hits. Caching outside logging means cache hits won't log. Document the intended order and use factory methods or dependency injection to enforce it consistently.
Try It Yourself
Build a simple decorator chain that adds retry logic and validation to a notification service. This example shows how decorators compose without modifying the core email sender.
Steps
- Create a new console project:
dotnet new console -n DecoratorPlayground
- Navigate to the folder:
cd DecoratorPlayground
- Replace the contents of Program.cs with the code below
- Update the project file as shown
- Run with
dotnet run
using System;
INotifier notifier = new EmailNotifier();
notifier = new ValidationDecorator(notifier);
notifier = new RetryDecorator(notifier, maxAttempts: 3);
notifier.Notify("alice@example.com", "Hello!");
interface INotifier
{
void Notify(string email, string message);
}
class EmailNotifier : INotifier
{
public void Notify(string email, string message) =>
Console.WriteLine($"Email sent to {email}: {message}");
}
class ValidationDecorator : INotifier
{
private readonly INotifier _inner;
public ValidationDecorator(INotifier inner) => _inner = inner;
public void Notify(string email, string message)
{
if (string.IsNullOrWhiteSpace(email) || !email.Contains("@"))
throw new ArgumentException("Invalid email address");
_inner.Notify(email, message);
}
}
class RetryDecorator : INotifier
{
private readonly INotifier _inner;
private readonly int _maxAttempts;
public RetryDecorator(INotifier inner, int maxAttempts)
{
_inner = inner;
_maxAttempts = maxAttempts;
}
public void Notify(string email, string message)
{
for (int attempt = 1; attempt <= _maxAttempts; attempt++)
{
try
{
_inner.Notify(email, message);
return;
}
catch when (attempt < _maxAttempts)
{
Console.WriteLine($"Retry attempt {attempt}");
}
}
}
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Output
Email sent to alice@example.com: Hello!
Choosing the Right Approach
Choose decorators when you need to mix and match features at runtime or when combining behaviors would require an explosion of subclasses. If you'd need LoggingCachingService, LoggingRetryService, CachingRetryService, and LoggingCachingRetryService, you'll get cleaner code by stacking three decorators instead of maintaining four classes.
Choose inheritance when you have a stable hierarchy with distinct types that don't need runtime composition. A Dog and a Cat both extend Animal, and you'll never need a DogCat hybrid. The relationship is fixed, and each subclass has unique state and behavior that makes sense as a permanent type.
If unsure, start with inheritance for genuine type relationships and switch to decorators when you find yourself duplicating cross-cutting concerns across subclasses. Watch for the moment you copy logging code into multiple classes. That's your signal to extract a decorator.
For migration, wrap existing classes with decorators without changing them. Add the interface if it's missing, then build decorators around the original implementation. You can move features from subclasses into decorators one at a time while keeping everything working.