LEARN BUILD GROW TOGETHER
DEVELOPER LEARNING HUB
Learn. Build. Grow Together.

Practical tutorials, real-world examples and interview preparation.

.NETC#SQL SERVERANGULAR
Home /ASP.NET Core /Design Patterns: Complete Developer Learning Guide

Design Patterns: Complete Developer Learning Guide

Software Design · Beginner to Advanced

Design Patterns: Complete Developer Learning Guide

Understand reusable object-oriented design solutions, learn the major creational, structural, and behavioral patterns, and apply them thoughtfully in C# and ASP.NET Core applications.

Design patterns are reusable approaches to recurring software design problems. They describe how responsibilities and collaborations can be organized; they are not ready-made code snippets that should be copied into every project.

This guide is technology-independent in its concepts and uses C#/.NET examples to make the ideas practical for backend and full-stack developers. The examples use modern C# syntax and the built-in dependency injection facilities available in ASP.NET Core. Adapt namespaces, persistence, and error handling to your application.

1. Design pattern foundations

What is a design pattern?

A design pattern is a named, documented approach to a recurring design challenge. It describes the context in which a problem appears, the structure of a possible solution, the roles involved, and the trade-offs. A pattern guides a design; it does not remove the need to understand the business requirement.

Why do developers use design patterns?

  • Shared vocabulary: Developers can discuss a design using names such as Strategy, Adapter, or Decorator.
  • Manageable change: Responsibilities can be separated so a change in one area is less likely to affect unrelated code.
  • Testability: Collaborators can often be replaced with fakes or test doubles.
  • Consistency: Familiar structures help teams understand and maintain code.
  • Trade-off awareness: Patterns make design choices and their costs more explicit.

Pattern vs principle vs architecture

ConceptMeaningExample
PrincipleA general guideline for making design decisions.Single Responsibility Principle, favor composition over inheritance.
PatternA reusable structure for a recurring design problem.Strategy, Factory Method, Adapter.
ArchitectureThe high-level organization of a system and its boundaries.Layered architecture, modular monolith, microservices.
Implementation techniqueA language or framework mechanism used to realize a design.Interfaces, delegates, generics, ASP.NET Core DI.
A pattern is not automatically a best practice. Apply it when the problem and trade-offs justify it. A straightforward method is often better than an elaborate pattern hierarchy.

2. The three classic pattern categories

The classic object-oriented patterns popularized by the Gang of Four (GoF) are commonly grouped into three categories.

CategoryFocusCommon patterns
CreationalHow objects are created while keeping construction decisions manageable.Singleton, Factory Method, Abstract Factory, Builder, Prototype.
StructuralHow objects and classes are composed into larger structures.Adapter, Decorator, Facade, Proxy, Composite, Bridge, Flyweight.
BehavioralHow objects communicate, share responsibilities, and choose behavior.Strategy, Observer, Command, Template Method, Chain of Responsibility, State, Mediator, Iterator, Visitor, Memento, Interpreter.

This guide explains frequently encountered patterns in detail. The remaining GoF patterns are included in the category map so the learning scope is clear; not every pattern needs a separate implementation in every application.

3. Design patterns and SOLID principles

SOLID and design patterns solve related but different design concerns. SOLID provides principles for structuring maintainable object-oriented code. Patterns provide named ways to organize collaborating objects in recurring situations.

  • Strategy can support the Open/Closed Principle by allowing new algorithms to be added as implementations of a common abstraction.
  • Decorator can add responsibilities without modifying the wrapped implementation.
  • Factory Method can move object-creation decisions away from the code that uses the object.
  • Adapter can protect application code from an external interface.
  • Dependency Injection helps supply collaborators from outside a class; it is not itself a substitute for good boundaries.
Do not force a pattern simply to demonstrate SOLID. First identify the responsibility, dependency, or change pressure that needs to be addressed.

4. Singleton Pattern

Problem it addresses

Sometimes an application needs one shared instance of a service within a defined lifetime. The Singleton pattern controls instance creation and exposes a shared access point.

Basic C# implementation

public sealed class AppSettingsCache
{
    private static readonly Lazy<AppSettingsCache> _instance =
        new(() => new AppSettingsCache());

    public static AppSettingsCache Instance => _instance.Value;

    private AppSettingsCache() { }

    public string EnvironmentName { get; set; } = "Production";
}

Singleton lifetime in ASP.NET Core

In ASP.NET Core, developers usually express a shared application-service lifetime through dependency injection rather than implementing a global static Singleton.

builder.Services.AddSingleton<IClock, SystemClock>();

The container creates and shares one instance for that service registration in the application's service provider. A singleton must be thread-safe if concurrent requests can use it. It must not capture scoped services such as a request-scoped DbContext.

When to use it

  • A stateless or carefully synchronized shared service.
  • An expensive shared resource whose lifecycle is application-wide.
  • Framework-managed services with a clear singleton lifetime.
Avoid using a static Singleton as a global container for mutable application state. It can make tests order-dependent, introduce concurrency bugs, and hide dependencies.

5. Factory Method Pattern

Problem it addresses

Client code should use an abstraction without needing to know which concrete implementation to construct. Factory Method delegates the creation decision to a creator or factory abstraction.

Example: notification sender

public interface INotificationSender
{
    Task SendAsync(string destination, string message,
        CancellationToken cancellationToken);
}

public sealed class EmailNotificationSender : INotificationSender
{
    public Task SendAsync(string destination, string message,
        CancellationToken cancellationToken)
    {
        // Integrate with an email provider.
        return Task.CompletedTask;
    }
}

public sealed class SmsNotificationSender : INotificationSender
{
    public Task SendAsync(string destination, string message,
        CancellationToken cancellationToken)
    {
        // Integrate with an SMS provider.
        return Task.CompletedTask;
    }
}

public interface INotificationSenderFactory
{
    INotificationSender Create(string channel);
}

public sealed class NotificationSenderFactory : INotificationSenderFactory
{
    private readonly EmailNotificationSender _email;
    private readonly SmsNotificationSender _sms;

    public NotificationSenderFactory(
        EmailNotificationSender email,
        SmsNotificationSender sms)
    {
        _email = email;
        _sms = sms;
    }

    public INotificationSender Create(string channel) =>
        channel.ToLowerInvariant() switch
        {
            "email" => _email,
            "sms" => _sms,
            _ => throw new ArgumentException("Unsupported channel.")
        };
}

The consumer asks the factory for a sender and works with INotificationSender. A production implementation would validate channel values and use real provider clients.

Factory Method vs Simple Factory

In everyday code, “factory” is often used broadly for any object-creation helper. The classic GoF Factory Method pattern specifically relies on a creator abstraction whose subclasses or implementations decide which product to create. A switch-based factory like the example above is commonly called a Simple Factory; it illustrates the creation idea but is not the strict GoF structure.

Use a factory when construction is conditional, complex, or needs to be centralized. Do not add a factory around a simple constructor call without a real creation problem.

6. Abstract Factory Pattern

Abstract Factory provides an interface for creating a family of related objects without exposing their concrete classes. It is useful when a group of components must work together consistently.

public interface IUiFactory
{
    IButton CreateButton();
    ITextBox CreateTextBox();
}

public interface IButton
{
    string Render();
}

public interface ITextBox
{
    string Render();
}

public sealed class LightButton : IButton
{
    public string Render() => "Light button";
}

public sealed class LightTextBox : ITextBox
{
    public string Render() => "Light text box";
}

public sealed class LightUiFactory : IUiFactory
{
    public IButton CreateButton() => new LightButton();
    public ITextBox CreateTextBox() => new LightTextBox();
}

A corresponding dark-theme factory could create a matching dark button and text box. The client depends on IUiFactory and receives a consistent family.

.NET use case: creating provider-specific components, document exporters, or a set of cloud-storage operations that must belong to the same provider family.

7. Builder Pattern

Builder separates the construction of a complex object from its final representation. It is useful when an object has many optional settings, staged construction, or validation rules.

public sealed class ReportRequest
{
    public required string Title { get; init; }
    public required DateOnly From { get; init; }
    public required DateOnly To { get; init; }
    public bool IncludeInactive { get; init; }
    public string Format { get; init; } = "PDF";
}

public sealed class ReportRequestBuilder
{
    private string? _title;
    private DateOnly _from;
    private DateOnly _to;
    private bool _includeInactive;
    private string _format = "PDF";

    public ReportRequestBuilder WithTitle(string title)
    {
        _title = title;
        return this;
    }

    public ReportRequestBuilder ForPeriod(DateOnly from, DateOnly to)
    {
        _from = from;
        _to = to;
        return this;
    }

    public ReportRequestBuilder IncludeInactive(bool value = true)
    {
        _includeInactive = value;
        return this;
    }

    public ReportRequestBuilder InFormat(string format)
    {
        _format = format;
        return this;
    }

    public ReportRequest Build()
    {
        if (string.IsNullOrWhiteSpace(_title))
            throw new InvalidOperationException("A title is required.");

        if (_to < _from)
            throw new InvalidOperationException("Invalid report period.");

        return new ReportRequest
        {
            Title = _title,
            From = _from,
            To = _to,
            IncludeInactive = _includeInactive,
            Format = _format
        };
    }
}
var request = new ReportRequestBuilder()
    .WithTitle("Monthly performance")
    .ForPeriod(new DateOnly(2026, 1, 1), new DateOnly(2026, 1, 31))
    .IncludeInactive()
    .InFormat("Excel")
    .Build();

For simple immutable data, a record with an object initializer or constructor may be enough. Builder is most useful when it makes complex construction easier to read and validate.

8. Prototype Pattern

Prototype creates a new object by copying an existing configured instance. It can be useful when initial setup is expensive or when users create variations of a template.

public sealed record EmailTemplate(
    string Subject,
    string Body,
    IReadOnlyDictionary<string, string> Tokens);

var original = new EmailTemplate(
    "Welcome",
    "Hello {{name}}",
    new Dictionary<string, string> { ["name"] = "Customer" });

// A new instance with one changed value.
var customized = original with
{
    Subject = "Welcome to the portal"
};

C# records provide convenient non-destructive copying with with. This is a shallow copy: referenced objects such as a mutable dictionary are still shared unless you copy them separately. Use explicit deep-copy logic when nested mutable state must be independent.

9. Adapter Pattern

Adapter makes one interface usable where another interface is expected. It is especially useful around third-party SDKs, legacy systems, and external APIs.

// Application-owned abstraction
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(
        decimal amount,
        string currency,
        CancellationToken cancellationToken);
}

public sealed record PaymentResult(bool Succeeded, string? Reference);

// A third-party SDK with a different interface
public interface IVendorPaymentsClient
{
    Task<string> CreateChargeAsync(
        long amountInMinorUnits,
        string currencyCode,
        CancellationToken cancellationToken);
}

public sealed class VendorPaymentAdapter : IPaymentGateway
{
    private readonly IVendorPaymentsClient _client;

    public VendorPaymentAdapter(IVendorPaymentsClient client)
        => _client = client;

    public async Task<PaymentResult> ChargeAsync(
        decimal amount,
        string currency,
        CancellationToken cancellationToken)
    {
        var minorUnits = checked((long)(amount * 100m));

        var reference = await _client.CreateChargeAsync(
            minorUnits, currency, cancellationToken);

        return new PaymentResult(true, reference);
    }
}

The application depends on its own IPaymentGateway, not the vendor's API. The adapter translates method calls and data formats at the boundary.

An Adapter translates an existing interface. It is different from a Facade, which offers a simpler interface to a subsystem.

10. Decorator Pattern

Decorator adds behavior to an object by wrapping it with another object that implements the same abstraction. Multiple decorators can be composed without creating a subclass for every combination.

public interface IOrderService
{
    Task<string> PlaceAsync(
        int customerId,
        CancellationToken cancellationToken);
}

public sealed class OrderService : IOrderService
{
    public Task<string> PlaceAsync(
        int customerId,
        CancellationToken cancellationToken)
        => Task.FromResult("Order created");
}

public sealed class LoggingOrderService : IOrderService
{
    private readonly IOrderService _inner;
    private readonly ILogger<LoggingOrderService> _logger;

    public LoggingOrderService(
        IOrderService inner,
        ILogger<LoggingOrderService> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public async Task<string> PlaceAsync(
        int customerId,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Placing order for {CustomerId}", customerId);
        var result = await _inner.PlaceAsync(customerId, cancellationToken);
        _logger.LogInformation("Order operation completed");
        return result;
    }
}

In a real application, register the inner service and compose the decorator using a deliberate registration approach or a DI extension that supports decorators. Avoid accidental circular registrations.

.NET examples: logging, metrics, caching, retry policies, authorization checks, and response transformations can be implemented as decorators or middleware depending on the boundary and scope.

11. Facade Pattern

Facade provides a simpler entry point to a set of subsystem components. It hides orchestration details from the caller without necessarily hiding every capability of the underlying services.

public sealed class CheckoutFacade
{
    private readonly InventoryService _inventory;
    private readonly PaymentService _payment;
    private readonly ShippingService _shipping;

    public CheckoutFacade(
        InventoryService inventory,
        PaymentService payment,
        ShippingService shipping)
    {
        _inventory = inventory;
        _payment = payment;
        _shipping = shipping;
    }

    public async Task<string> CheckoutAsync(
        int productId,
        int quantity,
        CancellationToken cancellationToken)
    {
        await _inventory.ReserveAsync(productId, quantity, cancellationToken);
        var paymentId = await _payment.CollectAsync(
            productId, quantity, cancellationToken);
        await _shipping.ScheduleAsync(paymentId, cancellationToken);

        return paymentId;
    }
}

The facade simplifies a caller's workflow. Production checkout also needs compensation or transaction design for partial failures; a facade alone does not make multiple remote operations atomic.

12. Proxy Pattern

Proxy provides a substitute object that controls access to another object. The proxy and real subject expose the same interface.

  • Virtual proxy: Delays creation or loading until needed.
  • Protection proxy: Checks whether a caller may access an operation.
  • Remote proxy: Represents an object accessed across a network boundary.
  • Caching proxy: Returns cached results when appropriate.

In .NET, an HTTP client wrapper that adds caching or access checks can act as a proxy. ASP.NET Core middleware and endpoint filters can address cross-cutting concerns at request boundaries, though they are not necessarily object proxies in the strict GoF sense.

13. Composite Pattern

Composite represents part-whole hierarchies so clients can treat individual objects and groups uniformly. It is useful for file trees, menu hierarchies, organization structures, and expression trees.

public interface IFileNode
{
    long GetSize();
}

public sealed class FileNode(long size) : IFileNode
{
    public long GetSize() => size;
}

public sealed class FolderNode : IFileNode
{
    private readonly List<IFileNode> _children = [];

    public void Add(IFileNode node) => _children.Add(node);

    public long GetSize() => _children.Sum(x => x.GetSize());
}

A client can call GetSize() on a file or folder without checking the concrete type. Consider access control, cycles, and mutation rules when building recursive structures.

14. Strategy Pattern

Strategy defines a family of interchangeable algorithms, encapsulates each one, and lets the caller select the behavior at runtime.

public interface IDiscountStrategy
{
    decimal Calculate(decimal subtotal);
}

public sealed class RegularDiscount : IDiscountStrategy
{
    public decimal Calculate(decimal subtotal) => 0m;
}

public sealed class MemberDiscount : IDiscountStrategy
{
    public decimal Calculate(decimal subtotal) => subtotal * 0.10m;
}

public sealed class SeasonalDiscount : IDiscountStrategy
{
    public decimal Calculate(decimal subtotal) => subtotal * 0.15m;
}

public sealed class PricingService
{
    public decimal GetFinalPrice(
        decimal subtotal,
        IDiscountStrategy strategy)
        => subtotal - strategy.Calculate(subtotal);
}

The strategy can be selected by business rules, configuration, or a registered set of implementations. Validate that a selected strategy is allowed for the current customer and transaction.

ASP.NET Core use case: selecting tax, pricing, shipping, file-export, or authentication-provider algorithms without building one large conditional method.

15. Observer Pattern

Observer defines a one-to-many relationship: when a subject changes state or publishes an event, interested observers are notified. This supports event-driven collaboration while reducing direct coupling between publisher and subscribers.

public sealed record OrderPlaced(int OrderId, DateTimeOffset OccurredAt);

public interface IOrderNotificationHandler
{
    Task HandleAsync(
        OrderPlaced notification,
        CancellationToken cancellationToken);
}

public sealed class OrderApplicationService
{
    private readonly IEnumerable<IOrderNotificationHandler> _handlers;

    public OrderApplicationService(
        IEnumerable<IOrderNotificationHandler> handlers)
        => _handlers = handlers;

    public async Task PublishAsync(
        OrderPlaced notification,
        CancellationToken cancellationToken)
    {
        foreach (var handler in _handlers)
            await handler.HandleAsync(notification, cancellationToken);
    }
}

This is a simplified in-process illustration. For reliable cross-process events, use a durable messaging system and an outbox/inbox strategy where appropriate. In-process notifications can be lost if the process stops before work completes.

16. Command Pattern

Command turns a request into an object that can be passed, queued, logged, retried, or executed later. It separates the sender of an operation from the object that performs it.

public interface ICommand
{
    Task ExecuteAsync(CancellationToken cancellationToken);
}

public sealed class RebuildSearchIndexCommand(
    ISearchIndexer indexer) : ICommand
{
    public Task ExecuteAsync(CancellationToken cancellationToken)
        => indexer.RebuildAsync(cancellationToken);
}

public sealed class CommandDispatcher
{
    public Task DispatchAsync(
        ICommand command,
        CancellationToken cancellationToken)
        => command.ExecuteAsync(cancellationToken);
}

Command objects are common in background jobs, queued work, undo/redo systems, and request/handler architectures. A command handler should define how validation, authorization, idempotency, and failure handling work.

17. Template Method Pattern

Template Method defines the overall steps of an algorithm in a base class while allowing subclasses to customize selected steps. It uses inheritance, so changes to the base workflow affect all derived classes.

public abstract class DataImporter
{
    public async Task ImportAsync(CancellationToken cancellationToken)
    {
        var data = await ReadAsync(cancellationToken);
        var validated = Validate(data);
        await SaveAsync(validated, cancellationToken);
    }

    protected abstract Task<string> ReadAsync(
        CancellationToken cancellationToken);

    protected virtual string Validate(string data)
        => string.IsNullOrWhiteSpace(data)
            ? throw new InvalidOperationException("No data.")
            : data;

    protected abstract Task SaveAsync(
        string data,
        CancellationToken cancellationToken);
}

Use Template Method when the workflow is stable and extension points are well-defined. If the steps vary independently or need runtime composition, Strategy or composition may be more flexible.

18. Chain of Responsibility Pattern

Chain of Responsibility passes a request through a sequence of handlers. Each handler can process the request, reject it, or pass it to the next handler.

ASP.NET Core middleware is a practical pipeline example: each middleware component can perform work before and after calling the next component, or short-circuit the pipeline.

app.Use(async (context, next) =>
{
    // Before the next component
    var started = Stopwatch.GetTimestamp();

    await next(context);

    // After the next component
    var elapsed = Stopwatch.GetElapsedTime(started);
    app.Logger.LogInformation(
        "Request completed in {Elapsed}", elapsed);
});

Middleware order matters. Authentication should normally run before authorization, and middleware that needs endpoint metadata must be placed where that metadata is available.

19. State Pattern

State allows an object to change its behavior when its internal state changes. Instead of scattering state-dependent conditionals throughout a class, behavior is delegated to state objects.

public interface IOrderState
{
    string Name { get; }
    IOrderState Ship();
    IOrderState Cancel();
}

public sealed class PendingOrderState : IOrderState
{
    public string Name => "Pending";
    public IOrderState Ship() => new ShippedOrderState();
    public IOrderState Cancel() => new CancelledOrderState();
}

public sealed class ShippedOrderState : IOrderState
{
    public string Name => "Shipped";
    public IOrderState Ship() => this;
    public IOrderState Cancel()
        => throw new InvalidOperationException(
            "A shipped order cannot be cancelled here.");
}

public sealed class CancelledOrderState : IOrderState
{
    public string Name => "Cancelled";
    public IOrderState Ship() => throw new InvalidOperationException();
    public IOrderState Cancel() => this;
}

For persisted workflows, store the state and enforce allowed transitions in the application/domain layer. A state object alone does not protect against concurrent updates or invalid data in the database.

20. Mediator Pattern

Mediator centralizes communication between collaborating components so they do not need direct references to one another. It can reduce a web of object-to-object dependencies, but an oversized mediator can become a central bottleneck.

In .NET applications, request/handler libraries and application-level dispatchers are often used in a mediator style. A command/query handler model can keep use cases separate, but adding a mediator library is optional; a small application may use direct service calls.

public sealed record GetOrderQuery(int OrderId);

public sealed class GetOrderHandler
{
    private readonly AppDbContext _db;

    public GetOrderHandler(AppDbContext db) => _db = db;

    public Task<Order?> HandleAsync(
        GetOrderQuery query,
        CancellationToken cancellationToken)
        => _db.Orders
            .AsNoTracking()
            .SingleOrDefaultAsync(
                x => x.Id == query.OrderId,
                cancellationToken);
}

This illustrates a query handler, not a complete mediator implementation. A mediator adds a dispatch mechanism that locates and invokes the appropriate handler.

21. Repository and Unit of Work patterns

Repository

Repository provides a collection-like abstraction for accessing domain objects or aggregates. It can isolate persistence details from business logic when the abstraction represents meaningful domain operations.

public interface IOrderRepository
{
    Task<Order?> GetByIdAsync(
        int id,
        CancellationToken cancellationToken);

    Task AddAsync(
        Order order,
        CancellationToken cancellationToken);
}

Unit of Work

Unit of Work coordinates changes made during a business operation and commits them as one logical unit. Entity Framework Core's DbContext already tracks changes and provides SaveChangesAsync as a unit-of-work-like mechanism for a context.

public sealed class OrderApplicationService(AppDbContext db)
{
    public async Task CreateAsync(
        Order order,
        CancellationToken cancellationToken)
    {
        db.Orders.Add(order);
        await db.SaveChangesAsync(cancellationToken);
    }
}
Do not automatically wrap every EF Core DbSet in a generic repository and every DbContext in another Unit of Work abstraction. Such layers can duplicate EF Core capabilities and make useful query features harder to access. Add an abstraction when it expresses a real boundary, domain operation, or testing need.

22. Design patterns in ASP.NET Core

ASP.NET Core already provides framework mechanisms that implement or support several patterns. Understanding these built-in facilities is more useful than recreating them from scratch.

ASP.NET Core / .NET featureRelated pattern or conceptTypical use
Built-in dependency injectionDependency Injection / IoCSupply services through constructors and manage service lifetimes.
Middleware pipelineChain of ResponsibilityAuthentication, exception handling, logging, routing, and other request processing.
Options patternConfiguration object patternBind and validate strongly typed configuration.
ILogger providersProvider / Factory-style extensibilityRoute logs to different destinations.
IHttpClientFactoryFactoryCreate configured HttpClient instances and manage handler lifetimes.
Endpoint filtersPipeline / Decorator-like compositionApply cross-cutting behavior around minimal API handlers.
Hosted servicesBackground processing abstractionRun application-managed background work.
EF Core DbContextUnit of Work and Repository-like capabilitiesTrack entity changes and query persistence data.

Dependency injection registration example

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<ISystemClock, SystemClock>();

Service lifetime reminder

  • Transient: A new instance is created each time the service is requested.
  • Scoped: One instance is created per scope. In a typical web request, that means one instance for that request scope.
  • Singleton: One instance is shared for the lifetime of the service provider.

Do not inject a scoped service into a singleton directly. Keep singleton services thread-safe and avoid storing request-specific state in them. Use constructor injection for required dependencies and avoid the service-locator style of calling IServiceProvider.GetService throughout application code.

23. Modern application patterns beyond GoF

Modern backend systems also use architectural and distributed-system patterns. These are not all GoF object-oriented patterns, but they address important problems in production applications.

PatternProblem it addressesWhere it may fit
Dependency InjectionSupplies dependencies from outside the consumer.ASP.NET Core services and testable components.
OptionsProvides strongly typed access to configuration.Connection settings, feature options, provider configuration.
CQRSSeparates operations that change state from operations that read state.Applications with distinct read/write needs or complex use cases.
SpecificationEncapsulates reusable business selection or validation criteria.Complex domain queries and business rules.
OutboxCoordinates database changes with reliable event publication.Transactional event-driven systems.
Inbox / Idempotent ConsumerDetects duplicate message processing.Message consumers and at-least-once delivery.
Retry with backoffHandles transient failures without immediate repeated pressure.Remote service calls, queues, and databases when safe.
Circuit BreakerStops repeated calls to a dependency that is failing.Distributed service communication.
BulkheadIsolates resource pools so one failure does not exhaust the whole system.Concurrent remote calls and background workloads.
Cache-AsideLoads data into a cache on demand and checks the cache before the source.Read-heavy workloads where staleness is acceptable and managed.
Strangler FigReplaces a legacy system incrementally by routing capabilities to new components.Modernization and migration projects.
Anti-Corruption LayerProtects a domain model from an external or legacy model.Integrations and bounded contexts.

CQRS: a small example

// Command: changes application state
public sealed record CreateOrderCommand(
    int CustomerId,
    decimal Total);

// Query: retrieves information
public sealed record GetOrderSummaryQuery(int OrderId);

CQRS does not require separate databases, event sourcing, microservices, or a messaging broker. It can be as simple as separate command and query models within one application. Introduce more infrastructure only when the requirements justify it.

Resilience patterns and current .NET practice

For outgoing HTTP calls, prefer the supported IHttpClientFactory integration and a maintained resilience approach rather than writing ad hoc retry loops. Retries should be bounded, use appropriate delays, and be applied only to operations that are safe to retry. A timeout, cancellation, and idempotency strategy should be designed together.

Distributed systems are different from in-process objects

A local method call and a remote message have different failure modes. Networks can fail, messages can be delivered more than once, and services can be unavailable independently. Patterns such as Outbox, Inbox, Circuit Breaker, and Saga address these distributed-system realities; a simple GoF object pattern cannot make a remote workflow atomic.

24. Choosing the right design pattern

Choose a pattern by starting with the design pressure, not by browsing a list of patterns and trying to insert one into the code.

Design pressurePatterns to investigate
Object creation is conditional or complex.Factory Method, Abstract Factory, Builder.
Several algorithms are interchangeable.Strategy.
An external API has an incompatible interface.Adapter.
Behavior must be added around an existing object.Decorator or Proxy.
A complex subsystem needs a simpler entry point.Facade.
A request should pass through ordered handlers.Chain of Responsibility.
Behavior changes with an object's lifecycle state.State.
Several components need to react to an event.Observer or a message-based event approach.
Work must be queued, recorded, or executed later.Command.
A database update and event publication must be coordinated.Outbox.

A practical decision process

  1. Describe the problem in plain language.
  2. Identify what changes frequently and what should remain stable.
  3. Check whether a simpler function, interface, or composition already solves it.
  4. Select a pattern whose structure addresses the actual pressure.
  5. Evaluate complexity, performance, testing, and maintenance costs.
  6. Document why the pattern exists so future developers understand the decision.

25. Common design pattern mistakes

  • Pattern overuse: Introducing abstractions for hypothetical future requirements.
  • Pattern naming without understanding: Calling every helper a Factory or every wrapper a Decorator.
  • Too many interfaces: Creating an interface for every class even when no meaningful boundary or substitution exists.
  • Confusing DI with a pattern solution: DI supplies objects; it does not decide the right responsibilities or architecture.
  • Ignoring lifetime and concurrency: A singleton can be unsafe if it holds mutable state or a scoped dependency.
  • Using inheritance by default: Deep inheritance trees can make behavior hard to predict. Prefer composition when behavior needs to be assembled.
  • Ignoring distributed failure: Treating remote calls like local method calls can lead to unsafe retries and partial operations.
  • Premature microservices: A distributed architecture introduces deployment, observability, networking, and data-consistency costs.

26. Practical refactoring exercise

Before: a service with many conditional branches

public decimal CalculateDiscount(string customerType, decimal total)
{
    if (customerType == "Regular")
        return 0;

    if (customerType == "Member")
        return total * 0.10m;

    if (customerType == "Seasonal")
        return total * 0.15m;

    throw new ArgumentException("Unknown customer type.");
}

This may be perfectly acceptable when the set of rules is small and stable. If discount algorithms are growing or changing independently, Strategy can make each rule separately testable.

After: use Strategy

public interface IDiscountStrategy
{
    decimal Calculate(decimal total);
}

public sealed class MemberDiscountStrategy : IDiscountStrategy
{
    public decimal Calculate(decimal total) => total * 0.10m;
}

public sealed class SeasonalDiscountStrategy : IDiscountStrategy
{
    public decimal Calculate(decimal total) => total * 0.15m;
}

public sealed class NoDiscountStrategy : IDiscountStrategy
{
    public decimal Calculate(decimal total) => 0m;
}

What improved?

  • Each algorithm has one focused implementation.
  • Each strategy can be unit tested independently.
  • New algorithms can be introduced without expanding one large calculation method.
  • The selection rule still needs a clear owner, such as a factory or application service.

Exercise: Add a CorporateDiscountStrategy, write tests for every strategy, and implement a selector that validates the customer type. Then compare the result with the original conditional implementation for readability and complexity.

27. Design review checklist

  • Does the pattern solve a real, clearly stated problem?
  • Can a simpler method or composition solve the same problem?
  • Are responsibilities and dependencies clear?
  • Are external systems isolated behind appropriate boundaries?
  • Are object lifetimes and thread-safety requirements understood?
  • Are failures, retries, cancellation, and idempotency considered?
  • Can the behavior be tested without unnecessary infrastructure?
  • Does the design remain understandable to the team?
  • Are trade-offs and reasons documented where they are not obvious?

28. Design patterns interview questions

  1. What is a design pattern?
    A reusable approach to a recurring design problem, including its context and trade-offs.
  2. What are the three GoF categories?
    Creational, Structural, and Behavioral.
  3. Factory vs Abstract Factory?
    A factory creates a product; Abstract Factory creates families of related products through a common interface.
  4. Factory Method vs Simple Factory?
    Simple Factory is a common creation helper, often using conditions. GoF Factory Method delegates creation to creator implementations.
  5. Strategy vs State?
    Strategy selects interchangeable algorithms; State changes behavior according to an object's lifecycle state.
  6. Adapter vs Facade?
    Adapter translates an incompatible interface; Facade simplifies access to a subsystem.
  7. Decorator vs Proxy?
    Decorator adds responsibilities; Proxy controls access to the underlying subject. Their structures can look similar.
  8. Is Dependency Injection a design pattern?
    Yes. It is a technique/pattern for supplying dependencies from outside a consumer and is commonly used to implement IoC.
  9. Does EF Core use Repository and Unit of Work concepts?
    DbSet provides repository-like querying and DbContext tracks changes and saves them, providing unit-of-work-like behavior.
  10. Should every class have an interface?
    No. Use abstractions where they provide a meaningful boundary, substitution point, or testing benefit.
  11. What is CQRS?
    An architectural pattern that separates commands that change state from queries that return data.
  12. What is the Outbox pattern?
    A way to persist an event record in the same database transaction as business data, then publish it reliably through a background process.

29. Further learning and official resources

Use authoritative documentation to verify framework behavior and explore architecture topics beyond object-oriented patterns.

Documentation evolves. Check the current documentation for the target .NET and ASP.NET Core version before adopting framework-specific APIs or package recommendations.