Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 092 / 100

Patterns inside .NET: a practical Design Patterns guide

The framework is a pattern catalog.

Expona Admin6 min read

After nearly two years of building distributed systems at scale, I've realized that Patterns inside .NET aren't something you bolt on top of the framework—they're baked into its DNA. The .NET ecosystem is essentially a living pattern catalog, and understanding how the framework itself implements these patterns will make you a better architect and developer.

Why the framework matters

Most design patterns courses teach you Gang of Four theory in a vacuum: abstract factories, decorators, strategies floating in conceptual space. But here's what I've learned in production: the patterns that matter most are the ones your framework already implements. When you understand how .NET's built-in patterns work, you stop fighting the framework and start leveraging it. You write less boilerplate. Your code becomes more testable. Your team understands the conventions.

Let me walk through four core patterns embedded in .NET that you're probably using without fully grasping their elegance.

Dependency injection—Built into the host

Before .NET Core, dependency injection was optional. You could reach for an IoC container or hand-wire everything. Now it's first-class. The host—that IHost or IWebHost you build with builder.Services—is a service container masquerading as application initialization.

I worked on a CRM platform where we had complex business logic spread across repositories, validators, and event handlers. Early versions scattered manual instantiation everywhere. Switching to DI didn't just make testing easier; it forced us to think about our dependencies explicitly. Suddenly, circular dependencies became visible. Lifetime issues surfaced in development instead of production.

Here's what a typical setup looks like:

typescript
// C# - typical ASP.NET Core startup
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository, OrderRepository>();
builder.Services.AddScoped<IOrderService, OrderService>();
builder.Services.AddSingleton<IEmailClient, EmailClient>();

var app = builder.Build();
app.Run();

The pattern here is Inversion of Control. You declare what you need, and the framework wires it up. Lifetimes matter: Transient (new instance every time), Scoped (per HTTP request in web apps), Singleton (one for the application lifetime). Getting these wrong is sneaky—a singleton that caches state across requests is a production bug waiting to happen.

What makes .NET's DI powerful is that it's built-in. You're not adding a third-party library; you're using what the framework provides. That means every library, every piece of the framework itself, uses the same container. Consistency matters.

Middleware—Chain of responsibility

ASP.NET Core middleware is textbook Chain of Responsibility pattern. Each middleware component is a link in a chain, and each one can either handle a request or pass it along.

I remember building a ride-hailing backend where we needed request logging, authentication, rate limiting, and error handling—all layered. Middleware was perfect. Each concern was isolated, testable, and in a predictable order.

typescript
// C# - middleware pipeline setup
app.UseMiddleware<RequestLoggingMiddleware>();
app.UseAuthentication();
app.UseAuthorization();
app.UseMiddleware<RateLimitingMiddleware>();
app.MapControllers();
app.Run();

Each middleware is a component that receives the next middleware in the chain:

typescript
// C# - custom middleware example
public class RequestLoggingMiddleware
{
    private readonly RequestDelegate _next;
    private readonly ILogger<RequestLoggingMiddleware> _logger;

    public RequestLoggingMiddleware(RequestDelegate next, ILogger<RequestLoggingMiddleware> logger)
    {
        _next = next;
        _logger = logger;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        _logger.LogInformation("Request: {Method} {Path}", context.Request.Method, context.Request.Path);
        
        await _next(context);
        
        _logger.LogInformation("Response: {StatusCode}", context.Response.StatusCode);
    }
}

The key insight: each middleware wraps the next one. Order matters enormously. Authentication must come before authorization. Logging should wrap everything. This ordering isn't magic—it's the natural result of chaining responsibility through delegates.

Options—IOptions is the options pattern

Configuration in .NET isn't just about reading JSON files. The Options pattern bridges configuration with dependency injection and validation.

When we built a trading platform, we had dozens of settings: API keys, database connection strings, feature flags, timeout values. We could have injected IConfiguration everywhere and scattered magic strings throughout the codebase. Instead, we created strongly-typed options classes:

typescript
// C# - options pattern setup
public class ExchangeApiOptions
{
    public string ApiKey { get; set; }
    public string BaseUrl { get; set; }
    public int TimeoutSeconds { get; set; }
    public bool EnableRateLimiting { get; set; }
}

// In startup
builder.Services.Configure<ExchangeApiOptions>(
    builder.Configuration.GetSection("ExchangeApi")
);

// In a service
public class ExchangeService
{
    private readonly ExchangeApiOptions _options;

    public ExchangeService(IOptions<ExchangeApiOptions> options)
    {
        _options = options.Value;
    }

    public async Task<Quote> GetQuoteAsync(string symbol)
    {
        var client = new HttpClient { Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds) };
        // use _options.ApiKey, _options.BaseUrl, etc.
    }
}

Why this matters: configuration becomes typed, validated, and scoped to what needs it. No magic strings in business logic. No accidental typos in config keys. The framework even validates at startup if you add data annotations to your options class.

For dynamic options that can change at runtime, use IOptionsMonitor<T> instead of IOptions<T>. That's the difference between a snapshot and a live watch.

Decorators and factories—IHttpClientFactory and DelegatingHandlers

This is where I see junior developers struggle most. IHttpClientFactory combines the Factory pattern with the Decorator pattern to handle HTTP concerns elegantly.

Early in my career, I'd create HttpClient instances everywhere. Performance nightmare. Connection pool exhaustion. Then I learned the right way involves factories and delegating handlers.

typescript
// C# - factory with decorators
builder.Services
    .AddHttpClient<IPaymentGateway, StripePaymentGateway>("stripe")
    .ConfigureHttpClient(client =>
    {
        client.BaseAddress = new Uri("https://api.stripe.com");
        client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}");
    })
    .AddTransientHttpErrorPolicy(p =>
        p.WaitAndRetryAsync(retryCount: 3, sleepDurationProvider: retryAttempt =>
            TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))
        )
    )
    .AddPolicyHandler(GetCircuitBreakerPolicy());

What's happening under the hood:

  • Factory: IHttpClientFactory creates and manages HttpClient instances. Solves the socket exhaustion problem by pooling connections.
  • Decorators: Delegating handlers wrap the HTTP layer. Retry policies, circuit breakers, logging—each is a decorator that adds behavior.

Here's a custom delegating handler for request/response logging:

typescript
// C# - custom delegating handler
public class LoggingHttpHandler : DelegatingHandler
{
    private readonly ILogger<LoggingHttpHandler> _logger;

    public LoggingHttpHandler(ILogger<LoggingHttpHandler> logger)
    {
        _logger = logger;
    }

    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request,
        CancellationToken cancellationToken)
    {
        _logger.LogInformation("HTTP {Method} {Uri}", request.Method, request.RequestUri);
        
        var response = await base.SendAsync(request, cancellationToken);
        
        _logger.LogInformation("HTTP {StatusCode}", response.StatusCode);
        return response;
    }
}

// In startup
builder.Services
    .AddHttpClient<IPaymentGateway, StripePaymentGateway>()
    .AddHttpMessageHandler<LoggingHttpHandler>();

Each handler decorates the next one. This is the Decorator pattern in action: add cross-cutting concerns without modifying core logic. You can compose retry, circuit breaker, logging, authorization, all stacked together.

Common mistakes

Over-abstracting DI: I've seen developers inject interfaces for simple value objects or utilities that don't need mocking. DI is powerful but adds indirection. Use it where you need testability or multiple implementations, not everywhere.

Middleware order confusion: Putting authorization before authentication will silently fail—requests get rejected before identity is established. The framework doesn't prevent this; you must understand the chain.

Options class as god object: Cramming unrelated settings into one options class defeats the purpose. Group related settings logically. Each major subsystem should have its own options class.

Creating HttpClient directly: I still see new HttpClient() in production code. Don't do this. It bypasses connection pooling and handler composition. Always use IHttpClientFactory.

Singleton DI with mutable state: A singleton service that modifies shared state is concurrency waiting to happen. If you need per-request state, use Scoped. If you need thread-local state, think harder—you probably don't need a singleton.

Key takeaways

  • .NET's host and DI container implement Inversion of Control so thoroughly that understanding it shapes how you think about dependency management.
  • Middleware is Chain of Responsibility, and order is semantic—authentication before authorization, logging wrapping everything.
  • The Options pattern enforces strongly-typed configuration and makes your codebase less brittle than scattered configuration values.
  • IHttpClientFactory combines Factory and Decorator patterns to solve real production problems (socket exhaustion, cross-cutting concerns) elegantly.
  • Study how .NET implements these patterns. It's the best learning available—production-hardened, battle-tested code. Your own patterns should aim for the same clarity.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles