01 Design Patterns Foundation
Strategy Pattern: Algorithm Encapsulation Answer The Strategy Pattern (GoF) defines a family of algorithms, encapsulates each one into an independent class, and makes them interchangeable at runtime. Intent: Enables the algorithm to vary independently from the clients that use it. Replaces conditional branches (switch/case or nested if/else) with polymorphic dispatch. Structure: Context (holds a reference to a Strategy and delegates execution), Strategy Interface (common contract for all supported variants), and Concrete Strategies (individual algorithm implementations). Dependency Injection Integration: In modern .NET, strategies are registered in the DI container and injected as a keyed service, factory, or collection (IEnumerable<IStrategy>). Class diagram: Strategy pattern class structure C# (Keyed Services Strategy in .NET 8)
public interface IPaymentProcessor
{
Task ProcessPaymentAsync (decimal amount, CancellationToken ct);
}
// Register in Program.cs:
// builder.Services.AddKeyedScoped<IPaymentProcessor, StripeProcessor>("Stripe");
// builder.Services.AddKeyedScoped<IPaymentProcessor, PayPalProcessor>("PayPal");
public class CheckoutService
{
private readonly IServiceProvider _serviceProvider;
public CheckoutService (IServiceProvider serviceProvider) => _serviceProvider = serviceProvider;
public async Task CheckoutAsync (string paymentMethod, decimal total, CancellationToken ct)
{
// Dynamically resolve strategy based on runtime user selection
var processor = _serviceProvider.GetKeyedService <IPaymentProcessor >(paymentMethod)
?? throw new NotSupportedException ($"Payment method {paymentMethod} is not supported." );
await processor.ProcessPaymentAsync (total, ct);
}
}
02 Design Patterns Foundation
Factory Answer Factory patterns centralize object creation when a public constructor is not enough for the caller to know which type to build. A simple factory method hides concrete types. Callers receive an abstraction. Abstract Factory (GoF) builds families of related objects (UI widgets for Win vs Mac) so products stay consistent. Use when creation depends on config, environment, tenant, or feature flags. Example: IPaymentProcessorFactory.Create(provider) returns StripeProcessor or PaypalProcessor from a string or enum. Pitfall: a factory that news every type in the system becomes a service locator. Keep the family small and intentional. Factory Method (GoF) lets a creator subclass decide which product to instantiate. Prefer that when the hierarchy owns creation. In .NET DI, ActivatorUtilities and typed factories cover many cases without a hand-rolled singleton factory. Flow diagram: Factory C#
public static class NotifierFactory
{
public static INotifier Create (string channel) => channel switch
{
"email" => new EmailNotifier (),
"sms" => new SmsNotifier (),
_ => throw new ArgumentException ("Unknown channel" )
};
}
03 Design Patterns Foundation
Repository Answer Repository mediates between domain and data access with a collection-like API over aggregate roots. Keeps EF Core or raw SQL details out of application services so domain language stays clear. Often paired with Unit of Work for transaction boundaries across several repository calls. Do not leak IQueryable everywhere unless you accept tight EF coupling into application and domain layers. Example: IOrderRepository.GetById(id) returns an Order aggregate. Save persists it. No DbSet in the service. Pitfall: one generic IRepository<T> for every table encourages anemic CRUD and blurs aggregate boundaries. Microsoft DDD samples typically expose repository methods shaped for use cases, not table columns. Prefer GetById, Add, and query methods named in ubiquitous language over FindAll with open filters. Flow diagram: Repository C#
public interface IUserRepository
{
Task <User ?> GetByIdAsync (int id, CancellationToken ct);
Task AddAsync (User user, CancellationToken ct);
Task SaveChangesAsync (CancellationToken ct);
}
04 Design Patterns Foundation
Observer Answer Observer (GoF): publishers notify subscribers when state changes without hard-wiring every listener into the subject. In .NET, events and IObservable<T> model the observer pattern for in-process notifications. MediatR notifications are observer-like for in-process domain events: many handlers react to one published fact. Unsubscribe or use weak events when lifetime differs, or you risk memory leaks from dangling handlers. Example: Order.StatusChanged event; UI and audit logger subscribe. Order does not reference those types directly. Pitfall: synchronous observer chains that call slow I/O block the publisher. Queue or async dispatch when work is heavy. Across process boundaries, messaging (bus, outbox) replaces in-memory observers while keeping the same publish/subscribe idea. Keep payloads immutable facts. Handlers should not assume call order unless you define it explicitly. Sequence diagram: Observer C#
public class StockTicker
{
public event Action <string , decimal >? PriceUpdated ;
public void OnTrade (string symbol, decimal price) =>
PriceUpdated ?.Invoke (symbol, price);
}
05 Design Patterns Foundation
Decorator Answer Decorator (GoF) wraps an object to add behavior while keeping the same interface, without a subclass for every mix. Logging, caching, retry, and authorization wrappers commonly use decorator shape. DI containers can decorate services with Scrutor or manual registration of wrapper types. Keep the interface identical so callers stay unaware of the wrapper chain. Example: CachingOrderRepository implements IOrderRepository, holds inner IOrderRepository, and caches GetById results. Pitfall: deep decorator stacks obscure stack traces and order of operations. Document and test the chain order. HttpClient DelegatingHandler and ASP.NET middleware follow the same wrap pattern for HTTP. If the wrapper changes the interface, that is an adapter, not a decorator. Flow diagram: Decorator C#
public class CachingUserService : IUserService
{
private readonly IUserService _inner;
private readonly IMemoryCache _cache;
public CachingUserService (IUserService inner, IMemoryCache cache)
{
_inner = inner;
_cache = cache;
}
public Task <User ?> GetAsync (int id) =>
_cache.GetOrCreateAsync ($"user:{id}" , entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan .FromMinutes (5);
return _inner.GetAsync (id);
})!;
}
06 Design Patterns Foundation
Singleton (with caveats) Answer Classic Singleton (GoF): one shared instance for the process, often exposed through a static Instance accessor. In DI, AddSingleton registers one instance for the application lifetime, not per HTTP request. Static singletons hide dependencies and hurt testability because callers reach global state. Thread-safe lazy initialization matters for static cases. Prefer DI over hand-rolled Instance properties. Example: a process-wide IMemoryCache or configuration holder registered with AddSingleton is fine when the lifetime matches. Pitfall: mutable process-wide state shared across requests causes race conditions and flaky parallel tests. GoF listed Singleton with caveats even then; modern .NET guidance prefers container-managed lifetimes. Scoped and transient lifetimes exist because not every service should be a singleton. Flow diagram: Singleton C#
// Prefer DI
builder.Services .AddSingleton <IConfigurationCache , ConfigurationCache >();
// Avoid in new code
public sealed class LegacyGate
{
public static LegacyGate Instance { get ; } = new ();
private LegacyGate () { }
}
07 Design Patterns Foundation
Unit of Work Answer Unit of Work groups multiple repository operations into one transaction that commits or rolls back together. DbContext in EF Core tracks changes and acts as a unit of work when you call SaveChanges once. Commit once at the end of a business operation so partial updates do not leave inconsistent data. Avoid long-lived contexts in web apps. One DbContext per HTTP request is the typical scoped lifetime. Example: add Order and OrderLines through repositories, then one SaveChanges. Failure rolls back both. Pitfall: calling SaveChanges inside every repository method defeats the unit-of-work boundary. When you wrap DbContext behind IUnitOfWork, keep the API thin: Save, maybe transaction begin/commit. Across databases or messaging, outbox patterns extend the same "one commit" idea beyond a single DbContext. Flow diagram: One commit for several repository changes C#
public sealed class PlaceOrderUnitOfWork
{
private readonly AppDbContext _db;
public PlaceOrderUnitOfWork (AppDbContext db) => _db = db;
public async Task ExecuteAsync (Order order, Payment payment, CancellationToken ct)
{
_db.Orders .Add (order);
_db.Payments .Add (payment);
await _db.SaveChangesAsync (ct); // one transaction boundary
}
}
08 Design Patterns Foundation
CQRS Answer CQRS means Command Query Responsibility Segregation. Writes and reads use different models and handlers. A command asks the system to change something (PlaceOrder). A query asks for data and leaves state alone (GetOrderSummary). The write path usually goes through an aggregate or domain model that enforces rules. The read path returns DTOs shaped for a screen or API. It can hit a denormalized view or a simple projection. One database is fine at first: two code paths, same store. A separate read database is an optimization when reads need it. People often mix CQRS with event sourcing. Event sourcing stores every change as events. You can use CQRS without that. In .NET, teams often dispatch commands and queries with MediatR, or call handlers directly from minimal APIs or controllers. Pitfall: forcing full CQRS and dual stores on a simple CRUD app adds cost without benefit. Split when read and write shapes diverge. Flow diagram: Command path vs query path C#
// --- Shared contracts ---
public record OrderLineDto (Guid ProductId , int Quantity , decimal UnitPrice );
public record OrderSummaryDto (
Guid Id ,
string CustomerName ,
decimal Total ,
string Status ,
DateTimeOffset PlacedAt );
// --- Write model (aggregate) ---
public sealed class Order
{
private readonly List <OrderLine > _lines = new ();
public Guid Id { get ; private set ; }
public Guid CustomerId { get ; private set ; }
public string Status { get ; private set ; } = "Placed" ;
public DateTimeOffset PlacedAt { get ; private set ; }
public IReadOnlyList <OrderLine > Lines => _lines;
private Order () { }
public static Order Place (Guid customerId, IReadOnlyList <OrderLineDto > lines)
{
if (customerId == Guid .Empty )
throw new ArgumentException ("Customer is required." , nameof(customerId));
if (lines is null || lines.Count == 0)
throw new InvalidOperationException ("An order needs at least one line." );
var order = new Order
{
Id = Guid .NewGuid (),
CustomerId = customerId,
PlacedAt = DateTimeOffset .UtcNow ,
};
foreach (var line in lines)
{
if (line.Quantity <= 0)
throw new InvalidOperationException ("Quantity must be positive." );
if (line.UnitPrice < 0)
throw new InvalidOperationException ("Unit price cannot be negative." );
order._lines.Add (new OrderLine (line.ProductId , line.Quantity , line.UnitPrice ));
}
return order;
}
public decimal Total () => _lines.Sum (l => l.Quantity * l.UnitPrice );
}
public sealed class OrderLine
{
public Guid ProductId { get ; }
public int Quantity { get ; }
public decimal UnitPrice { get ; }
public OrderLine (Guid productId, int quantity, decimal unitPrice)
{
ProductId = productId;
Quantity = quantity;
UnitPrice = unitPrice;
}
}
public interface IOrderRepository
{
Task AddAsync (Order order, CancellationToken ct);
Task SaveChangesAsync (CancellationToken ct);
}
// --- Command + handler ---
public record PlaceOrderCommand (Guid CustomerId , IReadOnlyList <OrderLineDto > Lines );
public sealed class PlaceOrderHandler
{
private readonly IOrderRepository _orders;
public PlaceOrderHandler (IOrderRepository orders) => _orders = orders;
public async Task <Guid > Handle (PlaceOrderCommand cmd, CancellationToken ct)
{
var order = Order .Place (cmd.CustomerId , cmd.Lines );
await _orders.AddAsync (order, ct);
await _orders.SaveChangesAsync (ct);
return order.Id ;
}
}
// --- Query + handler (read model / projection) ---
public record GetOrderSummaryQuery (Guid OrderId );
public sealed class GetOrderSummaryHandler
{
private readonly AppDbContext _db;
public GetOrderSummaryHandler (AppDbContext db) => _db = db;
public async Task <OrderSummaryDto ?> Handle (
GetOrderSummaryQuery query,
CancellationToken ct)
{
// Read path: no tracking, no SaveChanges, shape for the caller.
return await _db.Orders
.AsNoTracking ()
.Where (o => o.Id == query.OrderId )
.Select (o => new OrderSummaryDto (
o.Id ,
o.Customer .Name ,
o.Lines .Sum (l => l.Quantity * l.UnitPrice ),
o.Status ,
o.PlacedAt ))
.FirstOrDefaultAsync (ct);
}
}Wire it in an API POST runs the command handler and returns 201 with the new id. GET runs the query handler and returns 404 when the summary is missing. Handlers stay free of HTTP details; the endpoint only maps status codes. C#
app.MapPost ("/orders" , async (
PlaceOrderCommand cmd,
PlaceOrderHandler handler,
CancellationToken ct) =>
{
var id = await handler.Handle (cmd, ct);
return Results .Created ($"/orders/{id}" , new { id });
});
app.MapGet ("/orders/{id:guid}" , async (
Guid id,
GetOrderSummaryHandler handler,
CancellationToken ct) =>
{
var summary = await handler.Handle (new GetOrderSummaryQuery (id), ct);
return summary is null ? Results .NotFound () : Results .Ok (summary);
});When it helps List and detail screens need columns the write model does not care about. Writes need strict rules on an aggregate; reads need fast, simple queries. For plain CRUD with one shared shape, keep a single model until the split earns its cost.
09 Design Patterns Advanced
Advanced: singleton pitfalls Answer A process-wide mutable singleton is shared global state. Parallel tests step on each other when they mutate it. Static ServiceLocator.Current hides dependencies. Constructor injection with one composition-root registration stays visible. Lazy creation needs a safe pattern (type initializer, Lazy<T>, or a lock). A mutable public static field is easy to race. In modern .NET DI, AddSingleton covers most app-wide services. Hand-rolled Instance properties are rarely required. Example: a static CurrentUser bag filled per request leaks identity across threads if the singleton is process-wide. Pitfall: registering a captive dependency: a singleton that holds a scoped DbContext lives longer than the context. Prefer immutable configuration singletons. Put per-request state in scoped services or HttpContext, not static fields. Interview answer: explain lifetime, test isolation, and why DI singletons beat classic static Instance. Flow diagram: Global instance vs injected dependency C#
// Prefer
builder.Services .AddSingleton <IClock , SystemClock >();
public sealed class OrderService
{
private readonly IClock _clock;
public OrderService (IClock clock) => _clock = clock;
}
// Avoid in new code
public sealed class Locator
{
public static Locator Instance { get ; } = new ();
public OrderService Orders { get ; set ; }
}
10 Design Patterns Advanced
Advanced: decorator vs subclassing Answer A decorator wraps an instance and adds behavior at runtime without a new subclass for every mix of features. Subclassing bakes combinations at compile time. Logging plus retry plus caching as subclasses multiplies types. HttpClient DelegatingHandler is a decorator chain. ASP.NET middleware follows the same wrap pattern for HTTP. Decorator keeps the same interface. A different interface means an adapter, not a decorator. Example: LoggedRepository and CachedRepository wrap IOrderRepository. You stack them in DI instead of CachedLoggedOrderRepository. Pitfall: inheritance for optional features creates fragile bases when feature order or presence changes. GoF Decorator solves open/closed extension for responsibilities you can layer. Inheritance still fits true subtype variation. Choose decorator when features compose; choose subclass when the type truly is a specialized kind with LSP-safe behavior. Flow diagram: Wrap instead of subclass matrix C#
public interface IMailer { Task SendAsync (string to, string body); }
public sealed class SmtpMailer : IMailer
{
public Task SendAsync (string to, string body) => Task .CompletedTask ;
}
public sealed class LoggingMailer : IMailer
{
private readonly IMailer _inner;
public LoggingMailer (IMailer inner) => _inner = inner;
public async Task SendAsync (string to, string body)
{
Console .WriteLine ("send " + to);
await _inner.SendAsync (to, body);
}
}