Skip to content

GoF Design Patterns (in modern .NET)

Key Points

  • Most GoF patterns are invisible in modern .NET — language and framework features absorb them. The senior point isn't to memorize 23 textbook entries; it's to recognize the pattern when you're already using it.
  • The senior interview answer to "give me a Strategy example" isn't IShippingStrategy from a Java book. It's keyed services in .NET 8+, or IEqualityComparer<T>, or IHttpClientFactory's named clients.
  • Pattern names are vocabulary, not architecture. They speed up code review and design discussion. Naming a pattern in code where there isn't one creates false certainty.
  • Pattern disease — over-applying patterns to simple code — is a junior tell. Senior judgment: prefer plain code; reach for a pattern only when the alternative is genuinely worse.
  • Some patterns are anti-patterns most of the time. Hand-rolled Singleton in modern .NET is one. ICloneable is another. Don't introduce them.

Concepts (deep dive)

How to read this file

For each pattern below: 2–4 sentences on intent, a tight C# example, where you actually see it in .NET (the BCL or framework anchor), and anti-pattern: when it's overkill. Treat this as a reference card you consult during code review, not a tutorial.

Creational

Singleton

  • Intent: ensure a single instance, global access.
  • Modern .NET: services.AddSingleton<IFoo, Foo>(). The container is the global registry; the lifetime is the guarantee. Lazy<T> for thread-safe-init.
builder.Services.AddSingleton<IClock, SystemClock>();   // modern
  • Where in .NET: every AddSingleton registration; Console.Out.
  • Anti-pattern: hand-rolled Singleton (private ctor + static instance) when DI is available — hidden global state, untestable.

Factory Method / Abstract Factory

  • Intent: create objects without exposing construction. Abstract Factory creates families.
  • Modern .NET: IServiceProvider, keyed services (.NET 8+), IHttpClientFactory, IDbContextFactory<T>.
builder.Services.AddKeyedSingleton<IPaymentGateway, StripeGateway>("stripe");
builder.Services.AddKeyedSingleton<IPaymentGateway, BraintreeGateway>("braintree");

public class CheckoutService([FromKeyedServices("stripe")] IPaymentGateway gateway) { }
  • Where in .NET: IHttpClientFactory.CreateClient, IDbContextFactory<TContext>, keyed DI, LoggerFactory.CreateLogger<T>.
  • Anti-pattern: IFooFactory that just calls new Foo().

Builder

  • Intent: stepwise construction; fluent API.
  • Modern .NET: pervasive — WebApplication.CreateBuilder, HostBuilder, StringBuilder, ResiliencePipelineBuilder, EF ModelBuilder. Fluent builders are the .NET house style.
  • Anti-pattern: Builder for a 3-property record — use the constructor.

Prototype

  • Intent: create new objects by copying an existing one.
  • Modern .NET: record with (C# 9+) is the idiomatic shallow-clone. ICloneable is dead — ambiguous contract, returns object.
public record OrderDto(Guid Id, string Customer, decimal Total);
var modified = original with { Total = 110m };
  • Anti-pattern: implementing ICloneable — use record with or an explicit Copy(...) method.

Structural

Adapter

  • Intent: wrap an incompatible interface so it fits the one a client expects.
  • Modern .NET: Hexagonal-architecture port/adapter (cross-link Clean / Onion / Hexagonal).
public interface IEmailSender { Task SendAsync(string to, string body); }
public class SendGridAdapter(SendGridClient c) : IEmailSender { /* wraps third-party */ }
  • Where in .NET: ILoggerProvider (adapts third-party logging); EF DB providers.
  • Anti-pattern: Adapter for code you control — just change the interface.

Decorator

  • Intent: wrap an object to add behavior without changing it.
  • Modern .NET: Scrutor Decorate<T> for DI; DelegatingHandler chain in IHttpClientFactory.
public class CachingRepo(IRepository inner, IMemoryCache cache) : IRepository
{
    public Task<Order?> GetAsync(Guid id) =>
        cache.GetOrCreateAsync(id, _ => inner.GetAsync(id));
}

builder.Services.AddScoped<IRepository, EfRepository>();
builder.Services.Decorate<IRepository, CachingRepo>();
builder.Services.Decorate<IRepository, LoggingRepo>();   // outer wraps inner
  • Anti-pattern: decorator stack of 5+ layers — refactor to a pipeline.

Facade

  • Intent: simple unified interface over a more complex subsystem.
  • Modern .NET: application-service classes aggregating repositories; IFileProvider over IO; HttpClient over HttpMessageHandler.
public class OrderService(IOrderRepo orders, IInventoryRepo inv, IEmailSender mail)
{
    public async Task PlaceAsync(PlaceOrder cmd) { /* inv + orders + mail */ }
}
  • Anti-pattern: Facade that forwards a single call to one dependency — that's a forwarder, not a facade.

Composite

  • Intent: treat individual objects and groups uniformly via a tree.
  • Modern .NET: Razor/Blazor component trees; XAML visual tree; IConfiguration (sections within sections); expression trees.
  • Anti-pattern: forcing a tree onto flat data.

Proxy

  • Intent: stand-in for another object that controls access.
  • Modern .NET: Castle.DynamicProxy for AOP; EF Core lazy-loading proxies (generated subclasses); gRPC client stubs.
public class Order { public virtual Customer Customer { get; set; } = null!; }   // virtual ⇒ proxiable
builder.Services.AddDbContext<AppDb>(o => o.UseLazyLoadingProxies().UseSqlServer(...));
  • Anti-pattern: lazy loading by default in EF — chatty (N+1) queries. Prefer .Include or projections.

Behavioral

Strategy

  • Intent: interchangeable algorithms; pick one at runtime.
  • Modern .NET: keyed services (.NET 8+); IEqualityComparer<T>/IComparer<T>; Func<T,U> for the lightweight case.
builder.Services.AddKeyedSingleton<IShippingCalculator, StandardShipping>("standard");
builder.Services.AddKeyedSingleton<IShippingCalculator, ExpressShipping>("express");

public class Checkout(IServiceProvider sp)
{
    public decimal Calc(Order o, string m) => sp.GetRequiredKeyedService<IShippingCalculator>(m).Cost(o);
}
  • Where in .NET: StringComparer.OrdinalIgnoreCase; JsonSerializerOptions.PropertyNamingPolicy.
  • Anti-pattern: Strategy with 2 impls that never grow — use an if.

Observer

  • Intent: one-to-many notification.
  • Modern .NET: C# event; IObservable<T> and Rx; Channels (push-pull); MediatR INotification; INotifyPropertyChanged.
public class OrderNotifier
{
    public event EventHandler<OrderPlacedArgs>? OrderPlaced;
    public void Publish(Order o) => OrderPlaced?.Invoke(this, new(o));
}
  • Anti-pattern: event-driven spaghetti — handlers across 12 modules with no documented subscriber list.

Command

  • Intent: encapsulate a request as an object — parameterizable, queueable, loggable.
  • Modern .NET: MediatR IRequest<T> is canonical; CQRS commands; Hangfire jobs; MassTransit IConsumer<T>. Cross-link CQRS & MediatR.
public record PlaceOrder(Guid CustomerId, List<Line> Lines) : IRequest<Guid>;
await mediator.Send(new PlaceOrder(customerId, lines));
  • Anti-pattern: Command for synchronous direct calls — just call the method.

Chain of Responsibility

  • Intent: pass a request along a chain until one handler processes it.
  • Modern .NET: ASP.NET Core middleware pipeline is the textbook example; DelegatingHandler chain; MediatR pipeline behaviors; EF interceptors.
app.Use(async (ctx, next) =>
{
    if (ctx.Request.Path == "/health") { await ctx.Response.WriteAsync("OK"); return; }
    await next();
});
  • Anti-pattern: chains of 20+ steps with no naming convention — refactor to explicit composition.

Iterator

  • Intent: sequentially access collection elements without exposing internals.
  • Modern .NET: IEnumerable<T> + yield return; IAsyncEnumerable<T> (C# 8+). Built into the language.
public async IAsyncEnumerable<Order> StreamAsync([EnumeratorCancellation] CancellationToken ct)
{
    await foreach (var o in db.Orders.AsAsyncEnumerable().WithCancellation(ct))
        yield return o;
}
  • Anti-pattern: hand-writing IEnumerator<T> when yield return exists.

Mediator

  • Intent: encapsulate how objects interact, reducing direct references.
  • Modern .NET: MediatR, Wolverine, source-gen Mediator. Note: MediatR is both Mediator and Command simultaneously. Cross-link CQRS & MediatR.
  • Anti-pattern: Mediator for a two-component system — direct call is fine.

Template Method

  • Intent: skeleton of an algorithm in the base; subclasses override specific steps.
  • Modern .NET: BackgroundService.ExecuteAsync — framework calls StartAsync → schedules ExecuteAsync → handles shutdown; you write only the variant step. Also Controller.OnActionExecuting/Executed, UserStoreBase.
public class CleanupService(AppDb db) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken ct)
    {
        while (!ct.IsCancellationRequested) { /* work */ await Task.Delay(TimeSpan.FromHours(1), ct); }
    }
}
  • Anti-pattern: TM when composition works. Inheritance locks the hierarchy.

State

  • Intent: allow an object to alter behavior when its internal state changes.
  • Modern .NET: MassTransit Saga StateMachineSaga, workflow engines (Elsa, Workflow Core), Stateless library.
public class OrderSaga : MassTransitStateMachine<OrderSagaState>
{
    public State Submitted { get; private set; } = null!;
    public OrderSaga() { Initially(When(OrderSubmitted).TransitionTo(Submitted)); /* ... */ }
}
  • Anti-pattern: State for if (status == X) — two-state booleans don't need a pattern.

Memento

  • Intent: capture and restore an object's internal state without violating encapsulation.
  • Modern .NET: records' immutability + a Stack<TState> history list. Common in editors and design tools; rare in server code.
public record DocumentState(string Text, int Cursor);
public class Editor
{
    private readonly Stack<DocumentState> _history = new();
    public DocumentState Current { get; private set; } = new("", 0);
    public void Edit(DocumentState next) { _history.Push(Current); Current = next; }
    public void Undo() { if (_history.Count > 0) Current = _history.Pop(); }
}
  • Anti-pattern: Memento for server-side requests — event sourcing or domain events scale better.

Pattern disease — the senior tell

Senior: "We have a Strategy here, but it's a single if statement. What's the win?"
Junior: "It's more flexible."
Senior: "Flexible for what? Show me the second case."

The senior judgment: patterns add cost (indirection, more files, harder navigation). Pay the cost when the alternative is genuinely worse — not preemptively. Most well-written modern .NET has fewer explicit patterns than a Java app of the same size, because the language and framework absorb them.

Signs of pattern overuse: - Three-letter IFoo interfaces with one implementation, ever. - FooFactory whose only method is new Foo(). - Strategy with one strategy. - Decorator chains of 5+ wrappers, none of which adds business value. - Singletons not registered with DI.

Decision matrix — when to name a pattern vs just write code

Situation Name the pattern Just write code
Code review of a 200-line PR ✅ "this is Decorator"
Architecture diagram ✅ "Saga" / "Strategy"
Five-line method with an if
New abstraction in a library ✅ in docs
Domain layer business logic
Onboarding new dev to existing system

Naming patterns is a communication tool. Don't name patterns in code where there isn't one — false certainty is worse than no abstraction.


Code: correct vs wrong

❌ Wrong: hand-rolled Singleton

public sealed class Cache
{
    private static readonly Cache _instance = new();
    public static Cache Instance => _instance;
    private Cache() { }
}
// Untestable; hidden coupling; threading footguns.

✅ Correct: DI singleton

builder.Services.AddSingleton<ICache, MemoryCacheImpl>();

❌ Wrong: Strategy with one strategy

public interface IPriceStrategy { decimal Price(Item i); }
public class StandardPriceStrategy : IPriceStrategy { /* the only impl, ever */ }

✅ Correct: just a method

public static decimal Price(Item i) => i.UnitPrice * i.Quantity;

❌ Wrong: Factory that calls new

public class OrderFactory { public Order Create() => new Order(); }

✅ Correct: just new — or DI

var order = new Order();
// or
builder.Services.AddTransient<Order>();

❌ Wrong: ICloneable

public class Foo : ICloneable
{
    public object Clone() => new Foo { /* ... */ };   // shallow? deep? unclear
}

✅ Correct: record with or explicit copy

public record Foo(int X, string Y);
var copy = original with { X = 42 };

Design patterns for this topic

Pattern 1 — "Use the framework's pattern impl"

  • Intent: when the BCL/framework already implements the pattern (DI = Singleton/Factory; middleware = CoR; BackgroundService = TM), use that, not a hand-rolled version.

Pattern 2 — "Name the pattern in review and docs"

  • Intent: patterns are vocabulary. Use names where they aid communication; don't put them in code where they aren't.

Pattern 3 — "Strategy via keyed services"

  • Intent: modern .NET 8+ way to swap algorithms at runtime by key; replaces if/switch chains and homegrown registries.

Pattern 4 — "Decorator via Scrutor"

  • Intent: add cross-cutting (cache, log, retry) without modifying the inner type; DI-managed.

Pattern 5 — "State machine for saga workflows"

  • Intent: when a process has more than 3 states with transition rules, use a state machine library (MassTransit Saga, Stateless). Don't roll your own.

Pros & cons / trade-offs

Pattern Pros (when right) Cons (when overused)
Singleton (DI) Simple; testable Hand-rolled = footgun
Factory Encapsulates construction Empty wrapper around new
Builder Fluent complex setup Over-engineering for 3 props
Strategy Swap algorithms False flexibility for one impl
Decorator Cross-cutting Stack-of-5 unreadable
Chain of Responsibility Composable pipeline 20-step chain is spaghetti
Mediator Decoupling Indirection tax
Template Method Skeleton + variant Inheritance lock-in
State Workflow clarity Two-state boolean overkill
Observer Loose coupling Event spaghetti

When to use / when to avoid

  • Use patterns when the alternative (plain code) is genuinely worse — duplication, tangled state, untestable.
  • Use the names in code review, ADRs, design docs.
  • Avoid introducing patterns "for flexibility" without a concrete second case.
  • Avoid hand-rolled Singleton, ICloneable, empty Factory wrappers.
  • Avoid stacking 5+ decorators — refactor to a pipeline.

Interview Q&A

Q1. Why is Singleton-as-DI-lifetime not the same as GoF Singleton? DI lifetime is a registration choice — container-managed, swappable, testable. GoF Singleton is private static + private ctor: untestable, globally accessible, hidden coupling. Same intent, totally different mechanics.

Q2. Strategy vs State — difference? Strategy: client picks the algorithm; algorithms are interchangeable. State: object's behavior changes based on its own state; transitions internal. Strategy = "I'll use this one"; State = "I'll behave differently as I move through phases."

Q3. When is Decorator the wrong choice? When layered behaviors aren't independent. If every decorator needs to know about the others, you have a procedural pipeline pretending to be a stack — refactor to Chain of Responsibility.

Q4. Show me Chain of Responsibility in ASP.NET Core. The middleware pipeline: app.Use(async (ctx, next) => ...). Each component decides whether to short-circuit or pass through. UseAuthentication/UseAuthorization/MapControllers are links.

Q5. Is MediatR an example of Mediator or Command? Both. The mediator decouples senders from handlers (Mediator). IRequest<T> objects encapsulate operations as data (Command). The library combines them.

Q6. Pattern overuse — senior tell? Interfaces with one implementation forever; Factories that call new; Strategy with one strategy; class hierarchies that exist to satisfy a diagram. Senior trims these and keeps patterns that earn their cost.

Q7. How does record make Prototype obsolete? record ships with with expressions: var copy = original with { X = 42 };. Type-safe, returns the right type (not object), no manual Clone ceremony.

Q8. Where do you see Observer in modern .NET? C# event; IObservable<T>/Rx; INotifyPropertyChanged; MediatR INotification/Publish; Channels for async producer/consumer.

Q9. How is BackgroundService a Template Method? The base implements StartAsync/StopAsync/cancellation/shutdown — the skeleton. You override only ExecuteAsync — the variant step.

Q10. What's wrong with ICloneable? Returns object; doesn't say shallow or deep; predates generics; stigmatized in design guidelines. Use record with.

Q11. Adapter vs Facade? Adapter changes an interface (X → looks like Y). Facade simplifies (multiple types → one). Adapter: "make X look like Y." Facade: "I'll deal with X/Y/Z so callers don't have to."

Q12. When implement Iterator by hand? Almost never. yield return and IAsyncEnumerable<T> cover essentially every case. Exception: extreme low-allocation custom enumeration (ref struct).

Q13. Keyed services vs Strategy? Keyed services are a Strategy implementation: register multiple impls with different keys; resolve by key at runtime. The framework-blessed Strategy registry.

Q14. When is Builder more than fluent setters? When Build() does real work — validation, locking, branching. WebApplicationBuilder.Build() validates DI, locks the service collection, configures Kestrel.

Q15. Pattern overload symptoms in review? Abstractions with no second use case; folders mirroring GoF categories; juniors quoting GoF in comments. Senior intervention: simplify; let patterns emerge from need.


Gotchas / common mistakes

  • ⚠️ Hand-rolled SingletonDI does it.
  • ⚠️ Strategy with one impl — fake flexibility.
  • ⚠️ Factory wrapping new — empty ceremony.
  • ⚠️ Decorator stack of 5+ — refactor to a pipeline.
  • ⚠️ ICloneable — abandoned; use record with.
  • ⚠️ State for two states — booleans don't need a pattern.
  • ⚠️ Observer spaghetti — events with no documented subscribers.
  • ⚠️ Reimplementing what the framework gives — middleware = CoR; DI = Singleton + Factory; BackgroundService = TM.
  • ⚠️ Wrong pattern name in interview — "Mediator" when you mean "Facade" loses points fast.

Further reading