Design Patterns in Modern C# 12

Design Patterns in Modern C# 12

Julian Krause · April 28, 2025
Design PatternsC#.NET
ArchitectureEngineering Culture

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 Mar 10, 2026 · 17:08

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

Clara Mar 05, 2026 · 20:08

I agree, great article!

David Mar 07, 2026 · 23:08

Exactly! I had the same thought.

Anna Mar 11, 2026 · 17:08

Thanks for sharing — very helpful.

Ben Mar 12, 2026 · 17:08

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


Leave a comment

An unhandled error has occurred. Reload 🗙

Rejoining the server...

Rejoin failed... trying again in seconds.

Failed to rejoin.
Please retry or reload the page.

The session has been paused by the server.

Failed to resume the session.
Please retry or reload the page.