Design patterns interview questions

10 questions on design patterns in C#: strategy, factory, repository, observer, decorator, singleton, unit of work, and CQRS, including where each one goes wrong.

01Design PatternsFoundation

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); } }
02Design PatternsFoundation

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") }; }
03Design PatternsFoundation

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); }
04Design PatternsFoundation

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); }
05Design PatternsFoundation

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); })!; }
06Design PatternsFoundation

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() { } }
07Design PatternsFoundation

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 } }
08Design PatternsFoundation

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.
09Design PatternsAdvanced

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; } }
10Design PatternsAdvanced

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); } }
Sources and further reading