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
IShippingStrategyfrom a Java book. It's keyed services in .NET 8+, orIEqualityComparer<T>, orIHttpClientFactory'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.
ICloneableis 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.
- Where in .NET: every
AddSingletonregistration;Console.Out. - Anti-pattern: hand-rolled Singleton (
privatector +staticinstance) 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:
IFooFactorythat just callsnew Foo().
Builder
- Intent: stepwise construction; fluent API.
- Modern .NET: pervasive —
WebApplication.CreateBuilder,HostBuilder,StringBuilder,ResiliencePipelineBuilder, EFModelBuilder. 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.ICloneableis dead — ambiguous contract, returnsobject.
public record OrderDto(Guid Id, string Customer, decimal Total);
var modified = original with { Total = 110m };
- Anti-pattern: implementing
ICloneable— userecord withor an explicitCopy(...)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;DelegatingHandlerchain inIHttpClientFactory.
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;
IFileProviderover IO;HttpClientoverHttpMessageHandler.
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.DynamicProxyfor 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
.Includeor 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); MediatRINotification;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; MassTransitIConsumer<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;
DelegatingHandlerchain; 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>whenyield returnexists.
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 callsStartAsync→ schedulesExecuteAsync→ handles shutdown; you write only the variant step. AlsoController.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),
Statelesslibrary.
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
❌ Wrong: Strategy with one strategy
public interface IPriceStrategy { decimal Price(Item i); }
public class StandardPriceStrategy : IPriceStrategy { /* the only impl, ever */ }
✅ Correct: just a method
❌ Wrong: Factory that calls new
✅ Correct: just new — or DI
❌ Wrong: ICloneable
public class Foo : ICloneable
{
public object Clone() => new Foo { /* ... */ }; // shallow? deep? unclear
}
✅ Correct: record with or explicit copy
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/switchchains 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 Singleton — DI does it.
- ⚠️ Strategy with one impl — fake flexibility.
- ⚠️ Factory wrapping
new— empty ceremony. - ⚠️ Decorator stack of 5+ — refactor to a pipeline.
- ⚠️
ICloneable— abandoned; userecord 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.