Adding Functionality with the Decorator Pattern in C#

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.

INotificationService.cs
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.

LoggingNotificationDecorator.cs
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.

RateLimitingDecorator.cs
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.

Program.cs
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

  1. Create a new console project: dotnet new console -n DecoratorPlayground
  2. Navigate to the folder: cd DecoratorPlayground
  3. Replace the contents of Program.cs with the code below
  4. Update the project file as shown
  5. Run with dotnet run
Program.cs
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}");
            }
        }
    }
}
DecoratorPlayground.csproj
<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.

Quick FAQ

When should I use the Decorator pattern instead of inheritance?

Use decorators when you need to add behavior at runtime or combine multiple features without creating subclass explosions. If you'd need dozens of subclasses to cover all combinations, decorators compose cleanly. They also keep classes open for extension but closed for modification.

What's the gotcha with nested decorators?

Order matters. If you wrap logging outside caching, you'll log cache hits. Swap them and you won't see cached reads. Stack decorators intentionally and test the full chain. Use dependency injection to control decorator order from configuration.

How do I test decorated objects effectively?

Test each decorator in isolation with a mock or stub inner component. Then test common chains end-to-end. Verify that each decorator passes through calls it doesn't modify and correctly alters or wraps the calls it owns.

Does the Decorator pattern work with async methods?

Yes, decorators work perfectly with async. Just ensure your interface returns Task or Task<T> and that each decorator properly awaits the inner call before adding behavior. Use ConfigureAwait(false) in library code to avoid context captures.

Is it safe to use decorators with dependency injection?

Absolutely. Register decorators in the correct order and use named or keyed registrations to control which implementation you're wrapping. Many DI containers have built-in decorator support. Watch lifetime scopes to ensure decorators and components align.

Back to Articles