DEV_NET_CORE
GET_STARTED
Design & ArchitectureSoftware design principles and common .NET patterns

Factory, Builder, Strategy, Adapter, Decorator, Facade, Proxy, and Chain of Responsibility

Overview

Factory, Builder, Strategy, Adapter, Decorator, Facade, Proxy, and Chain of Responsibility are common object-oriented design patterns. They describe reusable ways to solve recurring software design problems. In C# and .NET, these patterns appear in everyday application code, framework internals, ASP.NET Core middleware, dependency injection, HTTP clients, validation pipelines, logging, caching, data access, payment processing, file generation, and integration with external systems.

Design patterns are not rules that must be applied everywhere. They are vocabulary and proven structures that help developers discuss and solve design problems. A pattern is useful when it reduces complexity, improves testability, improves extensibility, or makes responsibilities clearer. A pattern is harmful when it is applied mechanically and makes simple code harder to understand.

The patterns in this topic can be grouped by purpose:

PatternCategoryMain Purpose
FactoryCreationalEncapsulate object creation
BuilderCreationalConstruct complex objects step by step
StrategyBehavioralSwap algorithms or behaviors behind a common interface
AdapterStructuralMake an incompatible interface usable
DecoratorStructuralAdd behavior while keeping the same interface
FacadeStructuralProvide a simplified interface over a complex subsystem
ProxyStructuralControl access to another object
Chain of ResponsibilityBehavioralPass a request through a chain of handlers

These patterns are important for interviews because they test practical design judgment. Interviewers may ask you to explain the pattern, implement it in C#, compare it with similar patterns, or identify which pattern is already used in a framework feature.

For example:

  • ASP.NET Core middleware is similar to Chain of Responsibility because each middleware can handle the request, call the next middleware, or short-circuit the pipeline.
  • Dependency injection often works with Strategy because multiple implementations can be selected behind an interface.
  • IHttpClientFactory is a factory-style abstraction for creating configured HttpClient instances.
  • Decorator is commonly used for adding logging, caching, validation, retry, authorization, or metrics around an existing service.
  • Adapter is used when wrapping a third-party library or legacy API behind a project-specific interface.
  • Facade is used when a controller or application service should call one simple API instead of coordinating many subsystems.
  • Proxy is used for lazy loading, caching, access control, remote calls, or expensive object protection.

A strong answer should explain not only what each pattern is, but also:

  • What problem it solves.
  • When it is useful.
  • When it is overkill.
  • How it relates to dependency injection.
  • How to implement it in C#.
  • What trade-offs it introduces.
  • How to test code that uses it.
  • How it differs from similar patterns.

Core Concepts

What Design Patterns Are

A design pattern is a named, reusable solution structure for a common software design problem.

A pattern usually describes:

  • The problem.
  • The context.
  • The participating objects.
  • The relationships between those objects.
  • The trade-offs.
  • The consequences.
  • Common implementation variants.

Patterns are useful because they provide shared language.

Instead of saying:

Code
Let's create an object that wraps this service, implements the same interface, forwards calls to the original service, and adds logging before and after.

A developer can say:

Code
Let's use a decorator for logging.

Patterns are not copy-paste code. They are design ideas that can be implemented differently depending on the language and framework.

In modern C#, patterns often appear with:

  • Interfaces.
  • Abstract classes.
  • Records and DTOs.
  • Delegates and lambdas.
  • Dependency injection.
  • Extension methods.
  • Middleware pipelines.
  • Generic types.
  • Options pattern.
  • IEnumerable<T> injection.
  • Keyed services.
  • Source generators or dynamic proxies in advanced cases.

Pattern Categories

The classic categories are:

CategoryPurpose
CreationalHow objects are created
StructuralHow objects and classes are composed
BehavioralHow objects communicate and vary behavior

In this topic:

  • Factory and Builder are creational patterns.
  • Adapter, Decorator, Facade, and Proxy are structural patterns.
  • Strategy and Chain of Responsibility are behavioral patterns.

These categories help, but real-world patterns often overlap. For example, a factory may create strategies. A decorator may be part of a chain. A proxy may use a strategy for access control. A facade may use factories internally.

Factory Pattern

The Factory pattern encapsulates object creation. Instead of callers constructing concrete objects directly with new, they ask a factory to create the right object.

Use Factory when:

  • Object creation is complex.
  • The concrete type depends on input.
  • The caller should not know implementation details.
  • Creation requires configuration or dependencies.
  • You need to centralize creation rules.
  • You want to avoid large switch logic scattered across the codebase.

Simple example:

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

public sealed class EmailNotificationSender : INotificationSender
{
    public Task SendAsync(string recipient, string message, CancellationToken cancellationToken)
    {
        Console.WriteLine($"Email to {recipient}: {message}");
        return Task.CompletedTask;
    }
}

public sealed class SmsNotificationSender : INotificationSender
{
    public Task SendAsync(string recipient, string message, CancellationToken cancellationToken)
    {
        Console.WriteLine($"SMS to {recipient}: {message}");
        return Task.CompletedTask;
    }
}

Factory:

Code
public enum NotificationChannel
{
    Email,
    Sms
}

public sealed class NotificationSenderFactory
{
    private readonly IServiceProvider _serviceProvider;

    public NotificationSenderFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public INotificationSender Create(NotificationChannel channel)
    {
        return channel switch
        {
            NotificationChannel.Email =>
                _serviceProvider.GetRequiredService<EmailNotificationSender>(),

            NotificationChannel.Sms =>
                _serviceProvider.GetRequiredService<SmsNotificationSender>(),

            _ => throw new NotSupportedException($"Unsupported channel: {channel}")
        };
    }
}

Registration:

Code
builder.Services.AddTransient<EmailNotificationSender>();
builder.Services.AddTransient<SmsNotificationSender>();
builder.Services.AddSingleton<NotificationSenderFactory>();

Usage:

Code
public sealed class NotificationService
{
    private readonly NotificationSenderFactory _factory;

    public NotificationService(NotificationSenderFactory factory)
    {
        _factory = factory;
    }

    public Task SendAsync(
        NotificationChannel channel,
        string recipient,
        string message,
        CancellationToken cancellationToken)
    {
        var sender = _factory.Create(channel);

        return sender.SendAsync(recipient, message, cancellationToken);
    }
}

This centralizes the selection of the concrete sender.

Factory Pattern Variants

Factory is a broad term. Common variants include:

VariantDescription
Simple FactoryA class or method creates objects based on input
Factory MethodA base class defines a creation method that derived classes override
Abstract FactoryCreates families of related objects
DI FactoryUses dependency injection to resolve concrete services
Static Factory MethodA static method creates instances with meaningful names

Static factory example:

Code
public sealed class Money
{
    public decimal Amount { get; }
    public string Currency { get; }

    private Money(decimal amount, string currency)
    {
        Amount = amount;
        Currency = currency;
    }

    public static Money Usd(decimal amount)
    {
        return new Money(amount, "USD");
    }

    public static Money Eur(decimal amount)
    {
        return new Money(amount, "EUR");
    }
}

Usage:

Code
var price = Money.Usd(49.99m);

This is clearer than exposing a constructor that accepts arbitrary currency strings everywhere.

Factory Method example:

Code
public abstract class ReportExporter
{
    public async Task ExportAsync(Report report, string path)
    {
        var formatter = CreateFormatter();
        var content = formatter.Format(report);

        await File.WriteAllTextAsync(path, content);
    }

    protected abstract IReportFormatter CreateFormatter();
}

public sealed class CsvReportExporter : ReportExporter
{
    protected override IReportFormatter CreateFormatter()
    {
        return new CsvReportFormatter();
    }
}

In modern .NET applications, simple factories and DI-based factories are more common than inheritance-heavy Factory Method implementations.

Factory Pattern Trade-Offs

Benefits:

  • Centralizes creation logic.
  • Hides concrete types from callers.
  • Improves testability when combined with interfaces.
  • Supports runtime selection.
  • Reduces duplicated switch or new logic.
  • Keeps construction rules consistent.

Costs:

  • Adds another abstraction.
  • Can hide dependencies if overused.
  • Can become a service locator if it exposes IServiceProvider too broadly.
  • Can grow into a large conditional factory.
  • May be unnecessary when DI can inject the needed implementation directly.

Common mistakes:

  • Creating factories for every class.
  • Using factories when a normal constructor or DI registration is enough.
  • Putting business logic inside the factory.
  • Returning object instead of a meaningful interface.
  • Injecting IServiceProvider into many classes and resolving dependencies manually.
  • Building a complex abstract factory when a simple method is enough.

Best practices:

  • Use factories when creation logic is meaningful.
  • Keep factories focused on creation and selection.
  • Prefer typed factories over exposing IServiceProvider everywhere.
  • Use DI to supply dependencies to objects created by the factory.
  • Avoid factories that know too much about business workflows.

Builder Pattern

The Builder pattern constructs complex objects step by step. It is useful when an object has many optional parts, complex validation, readable test setup needs, or multiple construction representations.

Use Builder when:

  • Constructors have too many parameters.
  • Object construction requires multiple steps.
  • Test data setup is noisy.
  • You need readable object creation.
  • You need to enforce construction order.
  • You need to build different representations from similar inputs.

Example problem:

Code
var report = new Report(
    title: "Monthly Sales",
    startDate: new DateOnly(2026, 1, 1),
    endDate: new DateOnly(2026, 1, 31),
    includeCharts: true,
    includeSummary: true,
    includeDetails: false,
    format: ReportFormat.Pdf,
    timezone: "UTC");

This constructor is hard to read and easy to misuse.

Builder:

Code
public sealed class ReportRequest
{
    public required string Title { get; init; }
    public required DateOnly StartDate { get; init; }
    public required DateOnly EndDate { get; init; }
    public bool IncludeCharts { get; init; }
    public bool IncludeSummary { get; init; }
    public bool IncludeDetails { get; init; }
    public ReportFormat Format { get; init; } = ReportFormat.Pdf;
    public string Timezone { get; init; } = "UTC";
}
Code
public sealed class ReportRequestBuilder
{
    private string _title = "Untitled Report";
    private DateOnly _startDate = DateOnly.FromDateTime(DateTime.UtcNow);
    private DateOnly _endDate = DateOnly.FromDateTime(DateTime.UtcNow);
    private bool _includeCharts;
    private bool _includeSummary = true;
    private bool _includeDetails;
    private ReportFormat _format = ReportFormat.Pdf;
    private string _timezone = "UTC";

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

    public ReportRequestBuilder ForDateRange(DateOnly startDate, DateOnly endDate)
    {
        _startDate = startDate;
        _endDate = endDate;
        return this;
    }

    public ReportRequestBuilder IncludeCharts()
    {
        _includeCharts = true;
        return this;
    }

    public ReportRequestBuilder IncludeDetails()
    {
        _includeDetails = true;
        return this;
    }

    public ReportRequestBuilder AsExcel()
    {
        _format = ReportFormat.Excel;
        return this;
    }

    public ReportRequest Build()
    {
        if (_endDate < _startDate)
            throw new InvalidOperationException("End date cannot be before start date.");

        return new ReportRequest
        {
            Title = _title,
            StartDate = _startDate,
            EndDate = _endDate,
            IncludeCharts = _includeCharts,
            IncludeSummary = _includeSummary,
            IncludeDetails = _includeDetails,
            Format = _format,
            Timezone = _timezone
        };
    }
}

Usage:

Code
var request = new ReportRequestBuilder()
    .WithTitle("Monthly Sales")
    .ForDateRange(
        new DateOnly(2026, 1, 1),
        new DateOnly(2026, 1, 31))
    .IncludeCharts()
    .AsExcel()
    .Build();

The code is more readable and reduces constructor confusion.

Builder Pattern in Tests

Builders are very useful for test data.

Without builder:

Code
var order = new Order
{
    Id = Guid.NewGuid(),
    CustomerId = Guid.NewGuid(),
    Status = OrderStatus.Draft,
    CreatedAtUtc = DateTimeOffset.UtcNow,
    Items =
    {
        new OrderItem
        {
            ProductId = Guid.NewGuid(),
            UnitPrice = 10m,
            Quantity = 2
        }
    }
};

With test data builder:

Code
public sealed class OrderBuilder
{
    private readonly List<OrderItem> _items = new();
    private Guid _customerId = Guid.NewGuid();
    private OrderStatus _status = OrderStatus.Draft;

    public OrderBuilder ForCustomer(Guid customerId)
    {
        _customerId = customerId;
        return this;
    }

    public OrderBuilder WithItem(decimal unitPrice, int quantity)
    {
        _items.Add(new OrderItem
        {
            ProductId = Guid.NewGuid(),
            UnitPrice = unitPrice,
            Quantity = quantity
        });

        return this;
    }

    public OrderBuilder Submitted()
    {
        _status = OrderStatus.Submitted;
        return this;
    }

    public Order Build()
    {
        var order = new Order(_customerId);

        foreach (var item in _items)
        {
            order.AddItem(item.ProductId, item.UnitPrice, item.Quantity);
        }

        if (_status == OrderStatus.Submitted)
        {
            order.Submit();
        }

        return order;
    }
}

Usage:

Code
var order = new OrderBuilder()
    .WithItem(10m, 2)
    .WithItem(5m, 1)
    .Submitted()
    .Build();

This makes tests more focused on what matters.

Builder Pattern Trade-Offs

Benefits:

  • Improves readability for complex construction.
  • Avoids long constructors with many optional parameters.
  • Can enforce validation before object creation.
  • Helps test setup.
  • Supports fluent object creation.
  • Makes default values explicit.

Costs:

  • Adds extra code.
  • Can duplicate object properties.
  • Can hide required fields if poorly designed.
  • Can create mutable builder state.
  • May be overkill for simple objects.

Common mistakes:

  • Creating builders for simple DTOs with two or three properties.
  • Allowing invalid objects to be built.
  • Making builder methods too generic.
  • Using builders instead of proper domain constructors.
  • Putting business workflows inside builders.

Best practices:

  • Use builders for complex construction or test data.
  • Keep builders focused on construction.
  • Validate in Build.
  • Prefer meaningful method names such as Submitted() instead of WithStatus(OrderStatus.Submitted) when it improves readability.
  • Do not use builder to bypass domain invariants.

Strategy Pattern

The Strategy pattern defines a family of algorithms or behaviors behind a common interface and makes them interchangeable.

Use Strategy when:

  • You have multiple ways to perform the same kind of operation.
  • You want to avoid large switch statements.
  • You want to select behavior at runtime.
  • You want to test algorithms independently.
  • You want to add new behavior without changing existing code.
  • You want to follow Open/Closed Principle.

Example problem:

Code
public decimal CalculateShipping(Order order, string shippingMethod)
{
    return shippingMethod switch
    {
        "standard" => 10m,
        "express" => 25m,
        "overnight" => 50m,
        _ => throw new NotSupportedException()
    };
}

This works for small cases, but it grows poorly as logic becomes complex.

Strategy:

Code
public interface IShippingCostStrategy
{
    string Method { get; }

    decimal Calculate(Order order);
}

public sealed class StandardShippingStrategy : IShippingCostStrategy
{
    public string Method => "standard";

    public decimal Calculate(Order order)
    {
        return 10m;
    }
}

public sealed class ExpressShippingStrategy : IShippingCostStrategy
{
    public string Method => "express";

    public decimal Calculate(Order order)
    {
        return 25m;
    }
}

Resolver:

Code
public sealed class ShippingCostCalculator
{
    private readonly IReadOnlyDictionary<string, IShippingCostStrategy> _strategies;

    public ShippingCostCalculator(IEnumerable<IShippingCostStrategy> strategies)
    {
        _strategies = strategies.ToDictionary(
            strategy => strategy.Method,
            StringComparer.OrdinalIgnoreCase);
    }

    public decimal Calculate(Order order, string method)
    {
        if (!_strategies.TryGetValue(method, out var strategy))
            throw new NotSupportedException($"Unsupported shipping method: {method}");

        return strategy.Calculate(order);
    }
}

Registration:

Code
builder.Services.AddScoped<IShippingCostStrategy, StandardShippingStrategy>();
builder.Services.AddScoped<IShippingCostStrategy, ExpressShippingStrategy>();
builder.Services.AddScoped<ShippingCostCalculator>();

Now adding a new shipping method means adding a new class and registering it.

Strategy Pattern with Delegates

In C#, Strategy can also be implemented with delegates when behavior is simple.

Code
public sealed class DiscountCalculator
{
    private readonly Func<Customer, decimal, decimal> _discountStrategy;

    public DiscountCalculator(Func<Customer, decimal, decimal> discountStrategy)
    {
        _discountStrategy = discountStrategy;
    }

    public decimal Calculate(Customer customer, decimal subtotal)
    {
        return _discountStrategy(customer, subtotal);
    }
}

Usage:

Code
var calculator = new DiscountCalculator((customer, subtotal) =>
{
    return customer.IsPremium ? subtotal * 0.10m : subtotal * 0.02m;
});

Use interface-based strategies when:

  • The algorithm is complex.
  • The strategy has dependencies.
  • You need named implementations.
  • You need DI.
  • You need separate tests.
  • You expect multiple implementations.

Use delegates when:

  • The behavior is small.
  • No dependencies are needed.
  • A lightweight functional style is clearer.

Strategy Pattern Trade-Offs

Benefits:

  • Removes large conditionals.
  • Supports Open/Closed Principle.
  • Improves testability.
  • Makes algorithms independently replaceable.
  • Works well with dependency injection.
  • Keeps each behavior cohesive.

Costs:

  • Adds more classes.
  • Can be overkill for simple if or switch.
  • Requires strategy selection logic.
  • Can make flow harder to follow if overused.
  • May hide simple business rules behind unnecessary indirection.

Common mistakes:

  • Replacing every switch with Strategy even when the switch is simple and stable.
  • Creating strategies with unclear boundaries.
  • Putting selection logic inside each strategy.
  • Creating a large strategy interface that forces unused methods.
  • Forgetting to handle unknown strategy keys.

Best practices:

  • Use Strategy when behavior varies meaningfully.
  • Keep strategy interfaces small.
  • Keep each strategy focused.
  • Use DI for strategies with dependencies.
  • Use a resolver or keyed services for runtime selection.
  • Keep simple logic simple.

Adapter Pattern

The Adapter pattern converts one interface into another interface expected by the client.

Use Adapter when:

  • A third-party API has an inconvenient interface.
  • A legacy class does not match your application's interface.
  • You want to isolate external library details.
  • You need to convert data formats.
  • You want to protect your application from vendor changes.
  • You want a testable wrapper around external code.

Example: third-party payment SDK

Code
public sealed class ThirdPartyPaymentClient
{
    public Task ChargeCardAsync(
        string cardToken,
        int amountInCents,
        string currencyCode)
    {
        // External SDK call
        return Task.CompletedTask;
    }
}

Your application wants this interface:

Code
public interface IPaymentGateway
{
    Task<PaymentResult> ChargeAsync(
        PaymentRequest request,
        CancellationToken cancellationToken);
}

Adapter:

Code
public sealed class ThirdPartyPaymentAdapter : IPaymentGateway
{
    private readonly ThirdPartyPaymentClient _client;

    public ThirdPartyPaymentAdapter(ThirdPartyPaymentClient client)
    {
        _client = client;
    }

    public async Task<PaymentResult> ChargeAsync(
        PaymentRequest request,
        CancellationToken cancellationToken)
    {
        var amountInCents = (int)(request.Amount * 100);

        await _client.ChargeCardAsync(
            request.CardToken,
            amountInCents,
            request.Currency);

        return PaymentResult.Success();
    }
}

Registration:

Code
builder.Services.AddSingleton<ThirdPartyPaymentClient>();
builder.Services.AddScoped<IPaymentGateway, ThirdPartyPaymentAdapter>();

The rest of the application depends on IPaymentGateway, not the third-party SDK.

Adapter Pattern in Real .NET Projects

Common Adapter examples:

  • Wrapping a payment provider SDK.
  • Wrapping a legacy SOAP service behind a clean interface.
  • Wrapping a file system API behind IFileStorage.
  • Wrapping Azure Blob Storage behind IObjectStorage.
  • Wrapping SMTP behind IEmailSender.
  • Wrapping a message broker behind IMessagePublisher.
  • Mapping external DTOs to internal models.
  • Adapting old code to a new interface during migration.
  • Wrapping DateTime.UtcNow behind TimeProvider or a clock abstraction.

Example file storage adapter:

Code
public interface IFileStorage
{
    Task UploadAsync(
        string path,
        Stream content,
        CancellationToken cancellationToken);
}

public sealed class AzureBlobFileStorage : IFileStorage
{
    private readonly BlobContainerClient _container;

    public AzureBlobFileStorage(BlobContainerClient container)
    {
        _container = container;
    }

    public async Task UploadAsync(
        string path,
        Stream content,
        CancellationToken cancellationToken)
    {
        var blob = _container.GetBlobClient(path);

        await blob.UploadAsync(content, overwrite: true, cancellationToken);
    }
}

This adapter keeps cloud-specific code outside the application layer.

Adapter Pattern Trade-Offs

Benefits:

  • Isolates external APIs.
  • Protects domain/application code from vendor-specific details.
  • Improves testability.
  • Supports migration from legacy systems.
  • Makes integration boundaries explicit.
  • Converts incompatible interfaces.

Costs:

  • Adds mapping code.
  • Can hide important external behavior if oversimplified.
  • Requires maintenance when external APIs change.
  • May introduce performance overhead if excessive conversion occurs.

Common mistakes:

  • Letting third-party models leak through the adapter.
  • Making the adapter too generic.
  • Putting business rules in the adapter instead of application/domain logic.
  • Not handling external errors consistently.
  • Adapting only the happy path and ignoring failure modes.

Best practices:

  • Keep adapters at infrastructure boundaries.
  • Convert external models to internal models.
  • Normalize external errors into application-specific exceptions or results.
  • Do not leak vendor-specific types into core application code.
  • Write integration tests for adapters.
  • Use fake implementations for unit tests.

Decorator Pattern

The Decorator pattern adds behavior to an object while keeping the same interface. A decorator wraps another implementation of the same interface and forwards calls to it, adding behavior before or after.

Use Decorator when:

  • You want to add cross-cutting behavior.
  • You want to avoid modifying the original class.
  • You want to compose behaviors.
  • You want logging, caching, validation, retry, metrics, authorization, or transactions around a service.
  • You want behavior to be optional or configurable.
  • You want to follow Open/Closed Principle.

Base interface:

Code
public interface IProductService
{
    Task<ProductDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
}

Core implementation:

Code
public sealed class ProductService : IProductService
{
    private readonly AppDbContext _context;

    public ProductService(AppDbContext context)
    {
        _context = context;
    }

    public async Task<ProductDto?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        return await _context.Products
            .Where(product => product.Id == id)
            .Select(product => new ProductDto(product.Id, product.Name))
            .SingleOrDefaultAsync(cancellationToken);
    }
}

Caching decorator:

Code
public sealed class CachedProductService : IProductService
{
    private readonly IProductService _inner;
    private readonly IMemoryCache _cache;

    public CachedProductService(
        IProductService inner,
        IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<ProductDto?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        var cacheKey = $"product:{id}";

        if (_cache.TryGetValue(cacheKey, out ProductDto? cached))
            return cached;

        var product = await _inner.GetByIdAsync(id, cancellationToken);

        if (product is not null)
        {
            _cache.Set(cacheKey, product, TimeSpan.FromMinutes(5));
        }

        return product;
    }
}

The caller still depends on IProductService.

Decorator Pattern in .NET

Common .NET decorator examples:

  • Logging decorators.
  • Caching decorators.
  • Retry decorators.
  • Validation decorators.
  • Authorization decorators.
  • Metrics decorators.
  • Transaction decorators.
  • MediatR pipeline behaviors.
  • ASP.NET Core middleware.
  • Stream wrappers such as compression streams.
  • HttpMessageHandler chains.
  • ILogger provider pipelines.

Example logging decorator:

Code
public sealed class LoggingProductService : IProductService
{
    private readonly IProductService _inner;
    private readonly ILogger<LoggingProductService> _logger;

    public LoggingProductService(
        IProductService inner,
        ILogger<LoggingProductService> logger)
    {
        _inner = inner;
        _logger = logger;
    }

    public async Task<ProductDto?> GetByIdAsync(
        Guid id,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("Getting product {ProductId}.", id);

        var product = await _inner.GetByIdAsync(id, cancellationToken);

        _logger.LogInformation(
            "Product {ProductId} found: {Found}.",
            id,
            product is not null);

        return product;
    }
}

Decorators can be stacked:

Code
LoggingProductService
  -> CachedProductService
    -> ProductService

Order matters. Caching before logging produces different behavior than logging before caching.

Decorator Pattern Trade-Offs

Benefits:

  • Adds behavior without changing core implementation.
  • Supports composition.
  • Keeps cross-cutting concerns separate.
  • Improves Open/Closed Principle.
  • Makes behavior reusable.
  • Works well with interfaces and DI.

Costs:

  • More classes.
  • More registrations.
  • Debugging call flow can be harder.
  • Order of decorators matters.
  • Too many decorators can hide behavior.
  • Some DI containers need extra setup for decorators.

Common mistakes:

  • Changing the interface in the decorator.
  • Adding unrelated business logic to a cross-cutting decorator.
  • Creating decorators with hidden side effects.
  • Ignoring decorator order.
  • Wrapping too many layers around simple logic.

Best practices:

  • Decorators should implement the same interface.
  • Keep decorator behavior focused.
  • Make order explicit.
  • Use decorators for cross-cutting concerns.
  • Test core implementation and decorators separately.
  • Avoid decorators when a simple method call is clearer.

Facade Pattern

The Facade pattern provides a simplified interface over a complex subsystem.

Use Facade when:

  • A client must coordinate many services.
  • A subsystem is complex.
  • You want to hide implementation details.
  • You want a simpler use-case-level API.
  • You want to reduce coupling to many subsystem classes.
  • You want to make common workflows easier to use.

Example without facade:

Code
public sealed class CheckoutController : ControllerBase
{
    public async Task<IActionResult> Checkout(CheckoutRequest request)
    {
        await _inventory.ReserveAsync(request.Items);
        var payment = await _payments.ChargeAsync(request.Payment);
        var order = await _orders.CreateAsync(request, payment);
        await _shipping.CreateShipmentAsync(order);
        await _notifications.SendConfirmationAsync(order);

        return Ok(order.Id);
    }
}

The controller knows too much about the checkout workflow.

Facade:

Code
public interface ICheckoutFacade
{
    Task<CheckoutResult> CheckoutAsync(
        CheckoutRequest request,
        CancellationToken cancellationToken);
}
Code
public sealed class CheckoutFacade : ICheckoutFacade
{
    private readonly IInventoryService _inventory;
    private readonly IPaymentGateway _payments;
    private readonly IOrderService _orders;
    private readonly IShippingService _shipping;
    private readonly INotificationService _notifications;

    public CheckoutFacade(
        IInventoryService inventory,
        IPaymentGateway payments,
        IOrderService orders,
        IShippingService shipping,
        INotificationService notifications)
    {
        _inventory = inventory;
        _payments = payments;
        _orders = orders;
        _shipping = shipping;
        _notifications = notifications;
    }

    public async Task<CheckoutResult> CheckoutAsync(
        CheckoutRequest request,
        CancellationToken cancellationToken)
    {
        await _inventory.ReserveAsync(request.Items, cancellationToken);

        var payment = await _payments.ChargeAsync(
            request.Payment,
            cancellationToken);

        var order = await _orders.CreateAsync(
            request,
            payment,
            cancellationToken);

        await _shipping.CreateShipmentAsync(order, cancellationToken);
        await _notifications.SendConfirmationAsync(order, cancellationToken);

        return new CheckoutResult(order.Id);
    }
}

Controller:

Code
[HttpPost]
public async Task<ActionResult<CheckoutResult>> Checkout(
    CheckoutRequest request,
    CancellationToken cancellationToken)
{
    var result = await _checkout.CheckoutAsync(request, cancellationToken);

    return Ok(result);
}

The controller now depends on one simplified interface.

Facade Pattern Trade-Offs

Benefits:

  • Simplifies clients.
  • Reduces coupling to subsystem details.
  • Centralizes common workflow coordination.
  • Improves readability.
  • Makes complex subsystems easier to use.
  • Can create a stable API over changing internals.

Costs:

  • Facade can become a God service.
  • May hide important failure details.
  • Can centralize too much orchestration.
  • Can become a bottleneck for changes.
  • May duplicate application service responsibilities.

Common mistakes:

  • Calling every service facade.
  • Putting all business logic in one huge facade.
  • Hiding errors too aggressively.
  • Creating a facade that only forwards calls without simplifying anything.
  • Using a facade to cover poor subsystem design instead of improving it.

Best practices:

  • Use facades for meaningful workflows or subsystem simplification.
  • Keep facade methods use-case-oriented.
  • Avoid making facades too broad.
  • Do not hide important domain errors.
  • Keep subsystem boundaries clear.

Proxy Pattern

The Proxy pattern provides a substitute object that controls access to another object. A proxy usually implements the same interface as the real object and decides how or when to call it.

Use Proxy when:

  • You need lazy loading.
  • You need access control.
  • You need caching.
  • You need remote communication.
  • You need logging or metrics around access.
  • You need to protect an expensive object.
  • You need to delay object creation.
  • You need to control concurrency.

Example:

Code
public interface IReportLoader
{
    Task<Report> LoadAsync(Guid reportId, CancellationToken cancellationToken);
}

Real object:

Code
public sealed class DatabaseReportLoader : IReportLoader
{
    private readonly AppDbContext _context;

    public DatabaseReportLoader(AppDbContext context)
    {
        _context = context;
    }

    public async Task<Report> LoadAsync(
        Guid reportId,
        CancellationToken cancellationToken)
    {
        return await _context.Reports
            .SingleAsync(report => report.Id == reportId, cancellationToken);
    }
}

Caching proxy:

Code
public sealed class CachedReportLoaderProxy : IReportLoader
{
    private readonly IReportLoader _inner;
    private readonly IMemoryCache _cache;

    public CachedReportLoaderProxy(
        IReportLoader inner,
        IMemoryCache cache)
    {
        _inner = inner;
        _cache = cache;
    }

    public async Task<Report> LoadAsync(
        Guid reportId,
        CancellationToken cancellationToken)
    {
        var cacheKey = $"report:{reportId}";

        if (_cache.TryGetValue(cacheKey, out Report? cached))
            return cached!;

        var report = await _inner.LoadAsync(reportId, cancellationToken);

        _cache.Set(cacheKey, report, TimeSpan.FromMinutes(10));

        return report;
    }
}

This looks similar to Decorator. The difference is intent. Decorator adds responsibilities. Proxy controls access to the underlying object.

Proxy Pattern Variants

Common proxy types:

Proxy TypePurpose
Virtual ProxyLazy-load expensive objects
Protection ProxyCheck permissions before access
Remote ProxyRepresent an object in another process or service
Caching ProxyReturn cached results instead of calling real object
Logging/Monitoring ProxyObserve access to real object
Smart ProxyAdd lifecycle, reference counting, or concurrency control

Protection proxy example:

Code
public sealed class AuthorizedDocumentServiceProxy : IDocumentService
{
    private readonly IDocumentService _inner;
    private readonly ICurrentUser _currentUser;

    public AuthorizedDocumentServiceProxy(
        IDocumentService inner,
        ICurrentUser currentUser)
    {
        _inner = inner;
        _currentUser = currentUser;
    }

    public async Task<Document> GetAsync(
        Guid documentId,
        CancellationToken cancellationToken)
    {
        if (!_currentUser.HasPermission("documents.read"))
            throw new UnauthorizedAccessException();

        return await _inner.GetAsync(documentId, cancellationToken);
    }
}

Remote proxy example:

Code
public sealed class HttpCatalogProxy : ICatalogService
{
    private readonly HttpClient _httpClient;

    public HttpCatalogProxy(HttpClient httpClient)
    {
        _httpClient = httpClient;
    }

    public async Task<ProductDto?> GetProductAsync(
        Guid productId,
        CancellationToken cancellationToken)
    {
        return await _httpClient.GetFromJsonAsync<ProductDto>(
            $"api/products/{productId}",
            cancellationToken);
    }
}

The caller sees ICatalogService, while the proxy communicates with a remote API.

Proxy vs Decorator

Proxy and Decorator often look similar because both wrap another object and usually implement the same interface.

The difference is intent:

PatternMain Intent
DecoratorAdd behavior or responsibilities
ProxyControl access to the real object

Example:

  • A logging decorator adds logging around a service.
  • A caching proxy controls whether the real service is called.
  • An authorization proxy controls whether the real service can be accessed.
  • A remote proxy controls access to an object in another process.

In practice, some implementations can be described as either depending on intent. In interviews, explain the intent and trade-offs clearly.

Proxy Pattern Trade-Offs

Benefits:

  • Controls access to expensive or sensitive objects.
  • Supports lazy loading.
  • Adds security checks.
  • Encapsulates remote communication.
  • Can reduce repeated calls through caching.
  • Keeps caller interface stable.

Costs:

  • Adds indirection.
  • Can hide remote or expensive operations.
  • Caching proxies can return stale data.
  • Authorization proxies can duplicate policy logic if not designed carefully.
  • Dynamic proxies can be harder to debug.
  • Remote proxies may hide network failure complexity.

Common mistakes:

  • Making remote calls look too much like local calls and ignoring latency/failure.
  • Caching data without invalidation strategy.
  • Putting too much business logic in a proxy.
  • Using proxy when decorator or adapter is more accurate.
  • Hiding exceptions or failure modes.

Best practices:

  • Make expensive or remote behavior observable.
  • Keep proxy responsibility clear.
  • Use cancellation tokens for remote proxies.
  • Define caching and invalidation rules.
  • Do not hide important failure semantics.
  • Test access control and cache behavior.

Chain of Responsibility Pattern

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

Use Chain of Responsibility when:

  • Multiple handlers may process a request.
  • Processing steps should be composable.
  • Order matters.
  • Each step should be independent.
  • You want to add/remove steps without changing the caller.
  • You need a pipeline.
  • A request may be short-circuited.

ASP.NET Core middleware is a common example. Each middleware receives an HttpContext, can do work, can call next, or can stop the pipeline.

Simple custom chain:

Code
public sealed class SupportTicket
{
    public required string Title { get; init; }
    public required string Category { get; init; }
    public bool Handled { get; set; }
}

Handler interface:

Code
public interface ITicketHandler
{
    Task HandleAsync(
        SupportTicket ticket,
        TicketHandlerDelegate next,
        CancellationToken cancellationToken);
}

public delegate Task TicketHandlerDelegate(
    SupportTicket ticket,
    CancellationToken cancellationToken);

Handlers:

Code
public sealed class BillingTicketHandler : ITicketHandler
{
    public async Task HandleAsync(
        SupportTicket ticket,
        TicketHandlerDelegate next,
        CancellationToken cancellationToken)
    {
        if (ticket.Category == "billing")
        {
            ticket.Handled = true;
            return;
        }

        await next(ticket, cancellationToken);
    }
}

public sealed class TechnicalTicketHandler : ITicketHandler
{
    public async Task HandleAsync(
        SupportTicket ticket,
        TicketHandlerDelegate next,
        CancellationToken cancellationToken)
    {
        if (ticket.Category == "technical")
        {
            ticket.Handled = true;
            return;
        }

        await next(ticket, cancellationToken);
    }
}

Pipeline builder:

Code
public sealed class TicketPipeline
{
    private readonly IReadOnlyList<ITicketHandler> _handlers;

    public TicketPipeline(IEnumerable<ITicketHandler> handlers)
    {
        _handlers = handlers.ToList();
    }

    public Task HandleAsync(
        SupportTicket ticket,
        CancellationToken cancellationToken)
    {
        TicketHandlerDelegate terminal = (_, _) => Task.CompletedTask;

        var pipeline = _handlers
            .Reverse()
            .Aggregate(terminal, (next, handler) =>
            {
                return (currentTicket, token) =>
                    handler.HandleAsync(currentTicket, next, token);
            });

        return pipeline(ticket, cancellationToken);
    }
}

This creates a request pipeline.

Chain of Responsibility in ASP.NET Core

ASP.NET Core middleware is a practical Chain of Responsibility / pipeline example.

Code
app.Use(async (context, next) =>
{
    Console.WriteLine("Before next middleware");

    await next(context);

    Console.WriteLine("After next middleware");
});

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers();

Each middleware can:

  • Run before the next component.
  • Call the next component.
  • Run after the next component.
  • Short-circuit the request.
  • Add data to HttpContext.
  • Handle exceptions.
  • Apply authentication/authorization.
  • Add response headers.
  • Log request details.

Short-circuit example:

Code
app.Use(async (context, next) =>
{
    if (!context.Request.Headers.ContainsKey("X-Correlation-Id"))
    {
        context.Response.StatusCode = StatusCodes.Status400BadRequest;
        await context.Response.WriteAsync("Missing correlation id.");
        return;
    }

    await next(context);
});

The request is not passed further if the header is missing.

Chain of Responsibility vs Decorator

Both can involve wrapping and ordering.

PatternStructureMain Intent
DecoratorOne object wraps another object with the same interfaceAdd behavior to an object
Chain of ResponsibilityMultiple handlers process/pass a requestRoute or process a request through steps

Decorator usually wraps one service call. Chain usually processes a request through multiple independent handlers.

Example:

  • Logging decorator around IOrderService.
  • ASP.NET Core middleware chain processing HttpContext.

Chain of Responsibility Trade-Offs

Benefits:

  • Decouples sender from handlers.
  • Supports flexible pipelines.
  • Allows adding/removing/reordering handlers.
  • Supports short-circuiting.
  • Keeps each handler focused.
  • Useful for validation, middleware, authorization, and processing pipelines.

Costs:

  • Order can be hard to understand.
  • Debugging can be harder.
  • A request may not be handled if chain is misconfigured.
  • Too many handlers can create hidden behavior.
  • Shared mutable context can become messy.
  • Error handling across the chain needs design.

Common mistakes:

  • Making handlers depend heavily on each other.
  • Relying on unclear ordering.
  • Using global mutable context.
  • Swallowing errors in a handler.
  • Creating too many tiny handlers without clarity.
  • Not testing pipeline order.

Best practices:

  • Keep handlers focused.
  • Make ordering explicit.
  • Use a clear context object.
  • Avoid hidden side effects.
  • Add tests for important chain order.
  • Use short-circuiting intentionally.
  • Log or trace pipeline behavior when debugging matters.

Pattern Comparison Summary

PatternProblem It SolvesCommon .NET Example
FactoryObject creation varies or is complexIHttpClientFactory, service resolver
BuilderComplex object constructionTest data builders, options builders
StrategyAlgorithm/behavior variesPayment strategy, shipping calculation
AdapterInterface mismatchWrapper around third-party SDK
DecoratorAdd behavior to same interfaceLogging/caching around a service
FacadeSimplify complex subsystemCheckout facade coordinating services
ProxyControl access to objectLazy loading, caching, authorization, remote service proxy
Chain of ResponsibilityProcess request through handlersASP.NET Core middleware pipeline

Choosing the Right Pattern

Use this decision guide:

Code
Do I need to choose which object to create?
Use Factory.

Do I need to construct a complex object step by step?
Use Builder.

Do I need to swap algorithms or behaviors?
Use Strategy.

Do I need to make an incompatible API fit my interface?
Use Adapter.

Do I need to add behavior while keeping the same interface?
Use Decorator.

Do I need to simplify a complex subsystem?
Use Facade.

Do I need to control access to another object?
Use Proxy.

Do I need to pass a request through multiple handlers?
Use Chain of Responsibility.

Do not force a pattern. Many problems are solved best with simple methods, functions, or dependency injection.

Patterns and Dependency Injection

Dependency injection is not a replacement for design patterns, but it changes how they are implemented.

Examples:

  • Factory can use DI to create objects with dependencies.
  • Strategy implementations can be registered as IEnumerable<IStrategy>.
  • Decorators can wrap services registered in the container.
  • Adapters can be registered behind application interfaces.
  • Facades can receive subsystem services through constructor injection.
  • Proxies can be registered as implementations of the same interface.
  • Chain handlers can be registered in a specific order.

DI improves testability and makes dependencies explicit. However, overusing DI with unnecessary interfaces can make code harder to navigate.

Best practices:

  • Register patterns at meaningful boundaries.
  • Use constructor injection.
  • Avoid service locator style.
  • Prefer explicit dependencies.
  • Keep lifetimes correct.
  • Avoid injecting scoped services into singletons.
  • Keep runtime selection logic clear.

Patterns and Over-Engineering

Design patterns can improve architecture, but they can also become over-engineering.

Warning signs:

  • The pattern adds more complexity than it removes.
  • There is only one implementation and no real boundary.
  • A simple method becomes many classes.
  • Developers struggle to follow the call flow.
  • The pattern is used because it is "best practice" but no problem requires it.
  • Tests become harder instead of easier.
  • The abstraction has a vague name.
  • There are many empty pass-through classes.

Example over-engineering:

Code
Controller -> Facade -> Manager -> Processor -> Strategy -> Factory -> Handler -> Service

If most layers only forward calls, the design may violate KISS and YAGNI.

Good use of patterns should make code easier to understand or change.

Testing Code That Uses Patterns

Testing guidance:

PatternTesting Approach
FactoryTest selection logic and unsupported cases
BuilderTest required fields, defaults, validation, and object result
StrategyTest each strategy independently and resolver selection
AdapterUnit test mapping; integration test real external API wrapper
DecoratorTest added behavior and that inner service is called
FacadeTest orchestration and failure handling
ProxyTest access control, caching/lazy behavior, and fallback
Chain of ResponsibilityTest each handler and important pipeline order

Example strategy test:

Code
[Fact]
public void ExpressShippingStrategy_ReturnsExpectedCost()
{
    var strategy = new ExpressShippingStrategy();
    var order = new OrderBuilder().WithItem(10m, 2).Build();

    var cost = strategy.Calculate(order);

    Assert.Equal(25m, cost);
}

Example factory test:

Code
[Fact]
public void Create_WhenChannelIsEmail_ReturnsEmailSender()
{
    var factory = CreateFactory();

    var sender = factory.Create(NotificationChannel.Email);

    Assert.IsType<EmailNotificationSender>(sender);
}

Good tests verify behavior, not just pattern structure.

Common Mistakes

Common mistakes include:

  • Using patterns without a real problem.
  • Naming a class after a pattern instead of its domain role.
  • Creating too many interfaces.
  • Replacing simple conditionals with unnecessary Strategy classes.
  • Using Factory as a service locator.
  • Putting business logic inside object factories.
  • Using Builder for simple DTOs.
  • Letting Adapter leak third-party types into the core application.
  • Making Decorators change the interface contract.
  • Creating a Facade that becomes a God service.
  • Using Proxy while hiding important remote latency and failure.
  • Creating Chain of Responsibility handlers with unclear order.
  • Not testing pipeline order.
  • Ignoring DI lifetimes.
  • Overusing inheritance in Factory Method when simple composition would be clearer.
  • Confusing Decorator, Proxy, Adapter, and Facade.
  • Applying design patterns mechanically instead of pragmatically.

Best Practices

Use patterns to solve real design problems.

Prefer simple code until a pattern provides clear value.

Name classes by business purpose, not only by pattern name.

Use interfaces at meaningful boundaries.

Keep pattern implementations focused.

Keep object creation separate from business workflow logic.

Use Strategy for meaningful behavior variation.

Use Adapter to isolate external and legacy systems.

Use Decorator for cross-cutting behavior around a service.

Use Facade to simplify complex subsystem usage.

Use Proxy when access control, lazy loading, caching, or remote representation is the main concern.

Use Chain of Responsibility for pipelines with ordered handlers.

Use dependency injection carefully with correct lifetimes.

Test both the individual components and the selection/pipeline behavior.

Document ordering rules for pipelines and decorators.

Avoid over-engineering small features with too many patterns.

Interview Practice

PreviousThroughput, latency, concurrency, availability, consistency, and cost targetsNext UpKISS, DRY, YAGNI, Separation of Concerns, Cohesion, and Coupling