Modern C# design pattern catalog with decision guidance, when to use each pattern, when NOT to use, and which GoF patterns are replaced by language features. Trigger: design pattern, factory, builder, strategy, decorator, observer, mediator, singleton.
From dotnet-ai-kitnpx claudepluginhub faysilalshareef/dotnet-ai-kit --plugin dotnet-ai-kitThis skill uses the workspace's default tool permissions.
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Executes pre-written implementation plans: critically reviews, follows bite-sized steps exactly, runs verifications, tracks progress with checkpoints, uses git worktrees, stops on blockers.
Use DI as the modern replacement. Keep Factory Method only for polymorphic creation where the type is chosen at runtime.
// Modern: DI + factory delegate
services.AddScoped<Func<string, INotificationSender>>(sp => channel => channel switch
{
"email" => sp.GetRequiredService<EmailSender>(),
"sms" => sp.GetRequiredService<SmsSender>(),
_ => throw new ArgumentOutOfRangeException(nameof(channel))
});
Modern C# init properties and record positional syntax eliminate most Builder needs. Keep Builder only for complex multi-step construction with validation.
// Modern replacement: record with positional syntax
public sealed record OrderRequest(string CustomerId, List<LineItem> Items, string? CouponCode = null);
// Keep Builder when construction is complex and multi-step
public sealed class ReportBuilder
{
private readonly List<ReportSection> _sections = [];
public ReportBuilder AddSection(ReportSection section) { _sections.Add(section); return this; }
public Report Build() => _sections.Count == 0
? throw new InvalidOperationException("Report must have at least one section")
: new Report([.. _sections]);
}
init properties covers all fields cleanlyNever use the classic double-lock singleton. The DI container owns object lifetime.
// Modern: DI container manages the lifetime
services.AddSingleton<IDateTimeProvider, DateTimeProvider>();
services.AddSingleton<HybridCache>();
AddSingleton in DIstatic Instance pattern; let the DI container handle itWraps an incompatible interface so it conforms to the one your code expects.
public sealed class LegacyPaymentAdapter(LegacyPaymentApi legacy) : IPaymentGateway
{
public async Task<PaymentResult> ChargeAsync(decimal amount, CancellationToken ct)
{
var code = await legacy.ProcessPaymentXml($"<amount>{amount}</amount>");
return new PaymentResult(code == 0, code.ToString());
}
}
Adds behavior to an object without modifying it. MediatR pipeline behaviors are the modern poster child.
// MediatR pipeline behavior as Decorator
public sealed class LoggingBehavior<TRequest, TResponse>(
ILogger<LoggingBehavior<TRequest, TResponse>> logger)
: IPipelineBehavior<TRequest, TResponse> where TRequest : notnull
{
public async Task<TResponse> Handle(TRequest request,
RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
logger.LogInformation("Handling {Request}", typeof(TRequest).Name);
return await next(ct);
}
}
Provides a simplified API over a complex subsystem. Common in application services.
public sealed class OrderFacade(
IOrderRepository orders, IPaymentGateway payments, IInventoryService inventory)
{
public async Task<OrderResult> PlaceOrderAsync(PlaceOrderCommand cmd, CancellationToken ct)
{
await inventory.ReserveAsync(cmd.Items, ct);
var payment = await payments.ChargeAsync(cmd.Total, ct);
return await orders.CreateAsync(cmd, payment.TransactionId, ct);
}
}
Controls access to an object — commonly for lazy loading, caching, or authorization.
public sealed class CachedProductService(
IProductService inner, HybridCache cache) : IProductService
{
public async Task<Product?> GetByIdAsync(Guid id, CancellationToken ct)
=> await cache.GetOrCreateAsync($"product:{id}",
async token => await inner.GetByIdAsync(id, token), cancellationToken: ct);
}
Delegates and Func<T> are the lightweight alternative. Use DI for injectable strategies.
// Lightweight: Func<T> delegate
public sealed class PricingEngine(Func<Order, decimal> discountStrategy)
{
public decimal Calculate(Order order) => order.SubTotal - discountStrategy(order);
}
// DI-based: keyed services (.NET 8+)
services.AddKeyedScoped<IShippingCalculator, StandardShipping>("standard");
services.AddKeyedScoped<IShippingCalculator, ExpressShipping>("express");
Func<T> or lambda covers the variation without needing a full interfaceC# events and MediatR notifications replace the classic Observer pattern.
// Modern: MediatR notification (pub-sub)
public sealed record OrderPlacedEvent(Guid OrderId, decimal Total) : INotification;
public sealed class SendConfirmationEmail(IEmailSender email)
: INotificationHandler<OrderPlacedEvent>
{
public async Task Handle(OrderPlacedEvent e, CancellationToken ct)
=> await email.SendOrderConfirmationAsync(e.OrderId, ct);
}
MediatR IRequest is the standard modern implementation.
public sealed record CreateOrderCommand(string CustomerId, List<LineItem> Items)
: IRequest<Guid>;
public sealed class CreateOrderHandler(IOrderRepository repo, IUnitOfWork uow)
: IRequestHandler<CreateOrderCommand, Guid>
{
public async Task<Guid> Handle(CreateOrderCommand cmd, CancellationToken ct)
{
var order = Order.Create(cmd.CustomerId, cmd.Items);
repo.Add(order);
await uow.SaveChangesAsync(ct);
return order.Id;
}
}
MediatR itself implements this pattern — decouples senders from receivers.
// Controller sends; handler receives — no direct dependency
app.MapPost("/orders", async (CreateOrderCommand cmd, ISender sender) =>
Results.Created($"/orders/{await sender.Send(cmd)}", null));
ASP.NET Core middleware pipeline is the canonical modern example.
// Custom middleware — chain of responsibility
app.Use(async (context, next) =>
{
var sw = Stopwatch.StartNew();
await next(context);
context.Response.Headers.Append("X-Elapsed-Ms", sw.ElapsedMilliseconds.ToString());
});
Use enum-based state machines for simple cases. Reserve the full State pattern for complex transitions.
// Simple: enum + switch
public sealed class Order
{
public OrderState State { get; private set; } = OrderState.Draft;
public void Submit() => State = State switch
{
OrderState.Draft => OrderState.Submitted,
_ => throw new InvalidOperationException($"Cannot submit from {State}")
};
public void Approve() => State = State switch
{
OrderState.Submitted => OrderState.Approved,
_ => throw new InvalidOperationException($"Cannot approve from {State}")
};
}
| GoF Pattern | Modern C# Replacement | Example |
|---|---|---|
| Strategy | Func<T>, delegates | Func<Order, decimal> passed to constructor |
| Observer | event, INotification | MediatR notifications or C# events |
| Template Method | Func<T> parameters | Pass steps as delegates instead of subclassing |
| Prototype | record with with | order with { Status = "Shipped" } |
| Iterator | IEnumerable<T>, LINQ | yield return, foreach, LINQ operators |
| Singleton | DI AddSingleton | Container-managed lifetime |
| Command | record + MediatR | IRequest<T> with pipeline behaviors |
| Visitor | Pattern matching | Switch expressions with type patterns |
| Null Object | Nullable reference types | T? with null-conditional operators |
| Bridge | Generics + interfaces | IRepository<T> with generic constraints |
| Problem | Recommended Pattern | Why |
|---|---|---|
| Runtime type selection | Factory Method via DI delegate | Clean, testable, no new keywords |
| Complex object construction | Builder | Enforces step order and validation |
| Incompatible third-party API | Adapter | Isolates integration behind your interface |
| Cross-cutting concerns (logging, caching) | Decorator / MediatR behaviors | Composable without modifying core logic |
| Multiple handlers for one event | Observer via MediatR notifications | Decoupled pub-sub |
| Varying algorithm at runtime | Strategy via Func<T> or keyed DI | Lightweight, no interface explosion |
| Sequential request processing | Chain of Responsibility (middleware) | ASP.NET Core already provides the pipeline |
| Entity with complex lifecycle | State (enum-based first) | Prevents invalid transitions |
| Simplified entry point to subsystem | Facade | Reduces coupling for callers |
| Problem | Why It Hurts | Correct Approach |
|---|---|---|
| Applying every GoF pattern "just in case" | Massive over-engineering, hard to navigate | Add patterns only when a clear problem recurs |
Classic static Instance singleton | Untestable, hides dependencies, threading issues | Use AddSingleton in DI container |
| Interface for every class | Empty abstractions, ceremony without benefit | Add interfaces when you have 2+ implementations or need testability |
| God Factory that creates everything | Single point of coupling, hard to extend | Separate factories per concern or use DI delegates |
| Repository pattern wrapping EF's DbSet | Double abstraction over an already-abstract ORM | Use DbContext directly or a thin specification pattern |
| Mediator for every call including simple queries | Indirection without benefit, harder debugging | Use Mediator for commands with pipeline needs; inject services directly for simple reads |
| Strategy interface with one implementation | YAGNI overhead | Use a concrete class; extract interface later if needed |
| Deep decorator chains | Hard to debug, unclear execution order | Limit to 2-3 decorators; prefer MediatR pipeline behaviors for ordering clarity |
IPipelineBehavior — indicates MediatR decorator/pipeline usageINotification and INotificationHandler — Observer via MediatRIRequest<T> and IRequestHandler — Command via MediatRProgram.cs for AddSingleton, AddScoped, AddTransient patternsAddKeyedSingleton / AddKeyedScoped — keyed Strategy via DIapp.Use( middleware registrations — Chain of Responsibility