← Back to blog

Why You Should Use The Decorator Pattern

You know what's a fun way to spend a Tuesday afternoon? Opening a class that's been working perfectly fine in production for six months and cramming logging into it. Just really getting in there. Adding fields. Injecting services. Touching lines of code that have never hurt anyone. Maybe throw in a counter variable while you're at it. Why not. You're already in the file. Might as well redecorate the whole apartment while you're fixing the kitchen sink.

This is how most developers handle cross-cutting concerns, and it's unhinged.

The decorator pattern has existed since 1994. It is older than me. It solves this exact problem — adding behavior to a class without modifying it — and yet people keep choosing the "open it up and start performing surgery" approach like they've never heard of it. Which, to be fair, they might not have. Design patterns don't exactly come up in most bootcamps, and the original literature reads like it was written by academics who were allergic to practical examples.

So let's fix that.

The Setup

You're building a system that models car manufacturing. You've got a simple interface:

public interface ICarFactory
{
    ProductReceipt MakeCar();
}

A record for the result:

public record ProductReceipt(int Cost, int Value, string ProductName);

And two concrete factories — one for GMC Sierras, one for Chevy Silverados. Each one does exactly one thing: make a car, hand back a receipt:

public class GMCSierraFactory : ICarFactory
{
    public ProductReceipt MakeCar()
    {
        Console.WriteLine("Making a GMC Sierra...");
        return new(Cost: 25000, Value: 45000, ProductName: "GMC Sierra");
    }
}

public class ChevySilveradoFactory : ICarFactory
{
    public ProductReceipt MakeCar()
    {
        Console.WriteLine("Making a Chevy Silverado...");
        return new(Cost: 20000, Value: 35000, ProductName: "Chevy Silverado");
    }
}

Then you've got the business itself — GMC — which holds a dictionary of factories and routes requests to the right one:

public class GMC(Dictionary<string, ICarFactory> factories) : IBusiness
{
    public ProductReceipt MakeProduct(string productName)
    {
        if (factories.TryGetValue(productName, out var factory))
            return factory.MakeCar();

        throw new ArgumentException($"Product '{productName}' is not available.");
    }
}

Clean. Focused. Does one thing. Nobody's filed a bug against it. It's living its best life.

Then management walks in. They want production counts per factory. And profit tracking. By end of sprint. Because of course they do.

The Approach That Will Ruin Your Week

Your first instinct is going to be wrong. I know this because my first instinct was wrong too, and I'm sure yours is at least as bad as mine.

The instinct is to open GMCSierraFactory and start adding state to it. A counter. Maybe a TotalCarsMade property. Some Console.WriteLine calls to report progress. Your factory class that had one job — make a Sierra — is now also a statistician and a reporter. It has three responsibilities and an identity crisis.

Then you remember the Silverado factory needs the same thing. So you copy-paste the counter logic. You tell yourself you'll extract it later. You won't. Six months from now there will be a third factory, and someone will forget to add the counter, and nobody will notice until the quarterly numbers are wrong and someone has to explain it in a meeting.

This is the software equivalent of duct-taping a speedometer to the outside of your car. Technically it works. Spiritually it's a disaster.

The Decorator

A decorator implements the same interface as the thing it wraps. It accepts the original as a constructor dependency, delegates the actual work, and bolts on whatever behavior it wants before or after. The wrapped class has no idea it's being observed. It just keeps doing its job, blissfully unaware.

public class CarFactoryCounter(ICarFactory factory) : ICarFactory
{
    public int TotalCarsMade { get; private set; } = 0;

    public ProductReceipt MakeCar()
    {
        TotalCarsMade++;
        var receipt = factory.MakeCar();
        Console.WriteLine($"Total {receipt.ProductName} made: {TotalCarsMade}");
        return receipt;
    }
}

CarFactoryCounter is an ICarFactory. Anything that accepts an ICarFactory will accept this without blinking. It counts. It delegates. It reports. The GMCSierraFactory inside has no idea someone put a clipboard next to the assembly line. It's still just making trucks. Beautiful.

Same idea at the business level:

public class BusinessProfitCounter(IBusiness business) : IBusiness
{
    public int TotalProfit { get; private set; } = 0;

    public ProductReceipt MakeProduct(string productName)
    {
        var receipt = business.MakeProduct(productName);
        TotalProfit += receipt.Value - receipt.Cost;
        Console.WriteLine($"Profit from {receipt.ProductName}: {receipt.Value - receipt.Cost}");
        Console.WriteLine($"Total Profit so far: {TotalProfit}");
        return receipt;
    }
}

BusinessProfitCounter doesn't care what kind of IBusiness it wraps. GMC. Ford. A lemonade stand run by a twelve-year-old who somehow implemented IBusiness. Doesn't matter. It intercepts the result, does the math, passes it through.

The Part That Should Make You Feel Something

The consumer — GMCOwner — doesn't change. At all. Not one line.

public class GMCOwner(IBusiness business)
{
    public void RunBusiness()
    {
        business.MakeProduct("GMC Sierra");
        business.MakeProduct("GMC Sierra");
        business.MakeProduct("Chevy Silverado");
    }
}

Without decorators, the wiring looks like this:

var gmc = new GMC(new Dictionary<string, ICarFactory>
{
    { "GMC Sierra", new GMCSierraFactory() },
    { "Chevy Silverado", new ChevySilveradoFactory() }
});

new GMCOwner(gmc).RunBusiness();

With decorators:

var gmc = new GMC(new Dictionary<string, ICarFactory>
{
    { "GMC Sierra", new CarFactoryCounter(new GMCSierraFactory()) },
    { "Chevy Silverado", new CarFactoryCounter(new ChevySilveradoFactory()) }
});

var profitTracker = new BusinessProfitCounter(gmc);

new GMCOwner(profitTracker).RunBusiness();

Read that again. The factories didn't change. The business didn't change. The owner didn't change. You added production counting and profit tracking by changing how things are composed, not by modifying any class that was already working. Nobody opened a file that didn't need opening. Nobody introduced a regression. Nobody spent 40 minutes in a PR review arguing about whether the counter should be called _count or _totalCount or _numberOfCarsManufacturedSoFar.

The Open/Closed Principle — open for extension, closed for modification — isn't just something you say in interviews to sound like you read books. This is what it looks like when you actually do it.

Why This Matters Beyond the Example

Every time you shove a cross-cutting concern into a class that was doing its job just fine, that class gets a little worse. A little fatter. A little harder to test. Logging, caching, retry logic, metrics, validation — none of these are the work. They surround the work. They're the scaffolding, not the building. Mixing them into the core class is how you end up with a 400-line method that's 30% business logic and 70% observability noise, and the whole team treats it like a haunted house nobody wants to enter.

Decorators keep the core class focused. They keep cross-cutting concerns in their own little boxes. And they stack — you can wrap a counter in a logger in a retry handler in a cache layer, and each one only knows about its own job. It's separation of concerns that actually works instead of being a phrase you put on a slide and then immediately violate.

DI Makes This Even Better

If you're using dependency injection — and if you're not, that's a different blog post and a different conversation — decorators fit like they were designed for it. Because they were. Most DI containers support decorator registration natively, or through libraries like Scrutor. Instead of manually nesting new CarFactoryCounter(new GMCSierraFactory()), you tell your container: "whenever someone asks for an ICarFactory, hand them a CarFactoryCounter wrapping the real one." The consuming code never knows. It just asks for the interface and trusts the composition root to figure it out.

Stop Touching Things That Work

Next time you're about to open a working class and start adding behavior to it, close the file. Just close it. Ask yourself one question: does this class need to know about this new behavior, or does it just need to keep doing what it's already doing while something else watches?

If it's the second one — and it's almost always the second one — wrap it. The class was fine before you got there. Let it stay that way. You don't need to perform open-heart surgery every time someone asks for a new feature. Just put a coat on it and call it a day.