Design Patterns in Modern C# 12 cover image

Design Patterns in Modern C# 12

By Julian Krause · Monday, April 28, 2025 · ~3 min read

The Gang of Four patterns were written for C++ and Smalltalk. While the underlying principles remain sound, modern C# offers language features that dramatically simplify — or entirely replace — many classic pattern implementations. Here is how I implement common patterns in C# 12.

Strategy Pattern with Delegates

The classic Strategy pattern requires an interface, multiple concrete strategy classes, and a context class. In modern C#, a delegate or lambda often suffices.

// Classic approach — lots of ceremony
public interface IDiscountStrategy
{
    decimal CalculateDiscount(decimal total);
}

public class PercentageDiscount(decimal percentage) : IDiscountStrategy
{
    public decimal CalculateDiscount(decimal total) => total * percentage / 100;
}

// Modern approach — just use a Func
public class PricingService(Func<decimal, decimal> discountStrategy)
{
    public decimal CalculateTotal(decimal subtotal)
    {
        var discount = discountStrategy(subtotal);
        return subtotal - discount;
    }
}

// Registration
builder.Services.AddSingleton<Func<decimal, decimal>>(total => total * 0.1m);
builder.Services.AddTransient<PricingService>();

Use the interface approach when you need multiple methods, dependency injection, or testability. Use the delegate approach when the strategy is genuinely a single function.

Builder Pattern with Required Members

C# 11's required members and init-only properties make many uses of the Builder pattern unnecessary. The compiler itself enforces that all required properties are set.

// Builder pattern — still useful for complex construction
public class EmailBuilder
{
    private string _to = "";
    private string _subject = "";
    private readonly List<string> _attachments = [];

    public EmailBuilder To(string to) { _to = to; return this; }
    public EmailBuilder Subject(string subject) { _subject = subject; return this; }
    public EmailBuilder Attach(string path) { _attachments.Add(path); return this; }
    public Email Build() => new(_to, _subject, [.. _attachments]);
}

// Modern alternative — required members enforce completeness at compile time
public class EmailMessage
{
    public required string To { get; init; }
    public required string Subject { get; init; }
    public required string Body { get; init; }
    public List<string> Attachments { get; init; } = [];
}

// Compiler error if you forget To, Subject, or Body
var email = new EmailMessage
{
    To = "julian@krause.dev",
    Subject = "Design Patterns",
    Body = "Modern C# changes everything."
};

Discriminated Unions with Pattern Matching

Where you once needed the Visitor pattern to safely handle type hierarchies, C# pattern matching with exhaustive switch expressions provides the same guarantees with far less code.

public abstract record PaymentResult
{
    public record Success(string TransactionId, decimal Amount) : PaymentResult;
    public record Declined(string Reason) : PaymentResult;
    public record Error(Exception Exception) : PaymentResult;
}

public string HandlePayment(PaymentResult result) => result switch
{
    PaymentResult.Success s => $"Payment {s.TransactionId} completed: {s.Amount:C}",
    PaymentResult.Declined d => $"Payment declined: {d.Reason}",
    PaymentResult.Error e => $"Payment error: {e.Exception.Message}",
    _ => throw new UnreachableException()
};

Singleton with DI Container

The DI container in .NET makes manual Singleton implementations an anti-pattern. You get thread safety, lifecycle management, and testability for free.

// Never do this in modern .NET
public class LegacySingleton
{
    private static readonly Lazy<LegacySingleton> _instance = new(() => new());
    public static LegacySingleton Instance => _instance.Value;
    private LegacySingleton() { }
}

// Just register as singleton
builder.Services.AddSingleton<ICacheService, RedisCacheService>();

The meta-lesson is this: patterns are solutions to problems, not goals in themselves. If a language feature solves the same problem with less code and more safety, use the language feature.


Comments (3)

Hannah Tuesday, March 10, 2026 5:08 PM

I have a question: does this also apply to older versions?

Clara Thursday, March 5, 2026 8:08 PM

I agree, great article!

David Saturday, March 7, 2026 11:08 PM

Exactly! I had the same thought.

Anna Wednesday, March 11, 2026 5:08 PM

Thanks for sharing — very helpful.

Ben Thursday, March 12, 2026 5:08 PM

Could you elaborate on this topic in a follow-up post?

Leave a Comment