.NET 10 and C# 12 have reshaped how design patterns are implemented — minimal APIs, record types, improved pattern matching, and async streams reduce boilerplate and make patterns more expressive. This article covers 10 patterns with practical code examples tailored to modern .NET development.
1. Minimal API Pipeline Pattern
Groups related endpoints to share filters, authentication, and OpenAPI metadata — replacing bloated controllers with concise, functional definitions.
var userEndpoints = app.MapGroup("/api/users")
.WithTags("Users")
.WithOpenApi()
.RequireAuthorization();
userEndpoints.MapGet("/{id:int}", async (int id, HttpContext context) =>
{
if (id <= 0) return Results.BadRequest("Invalid user ID");
await Task.Delay(100);
return Results.Ok(new User(id, $"User {id}", $"user{id}@example.com"));
}).WithName("GetUser")
.WithDescription("Retrieves a user by ID");
userEndpoints.MapPost("/", async (UserInput input, HttpContext context) =>
{
if (string.IsNullOrEmpty(input.Name)) return Results.BadRequest("Name is required");
await Task.Delay(100);
var newUser = new User(1, input.Name, input.Email);
return Results.Created($"/api/users/{newUser.Id}", newUser);
}).WithName("CreateUser");
record User(int Id, string Name, string Email);
record UserInput(string Name, string Email);
RequireAuthorization and WithOpenApi apply to all endpoints in the group. C# 12 records make DTOs immutable and concise.
2. Result Pattern
Replaces exception-based error handling with a typed result object — explicit, predictable, and easy to unit test.
public abstract record Result<T>
{
private Result() { }
public record Success(T Value) : Result<T>;
public record Failure(string Error, int? StatusCode = null) : Result<T>;
}
public class UserService
{
private readonly Dictionary<int, User> _users = new()
{
{ 1, new User(1, "Alice", "alice@example.com") }
};
public async Task<Result<User>> GetUserAsync(int id)
{
if (id <= 0) return new Result<User>.Failure("Invalid user ID", 400);
await Task.Delay(100);
return _users.TryGetValue(id, out var user)
? new Result<User>.Success(user)
: new Result<User>.Failure("User not found", 404);
}
}
// Usage — C# 12 pattern matching
var result = await service.GetUserAsync(1);
switch (result)
{
case Result<User>.Success(var user):
Console.WriteLine($"Found: {user.Name}");
break;
case Result<User>.Failure(var error, var statusCode):
Console.WriteLine($"Error {statusCode}: {error}");
break;
}
No try-catch blocks. Outcomes are explicit in the return type. The StatusCode field makes it HTTP-friendly.
3. Functional Command Pattern
Encapsulates business logic as immutable pure functions — deterministic, side-effect-free, and trivially testable.
public record Order(int Id, decimal Amount, string Customer);
public static class OrderProcessor
{
public static Func<Order, Task<Result<string>>> CreateProcessOrderCommand(
decimal discount, ILogger logger)
{
return async order =>
{
if (order.Amount <= 0)
return new Result<string>.Failure("Invalid order amount");
logger.LogInformation("Processing order {OrderId}", order.Id);
var finalAmount = order.Amount - (order.Amount * discount);
await Task.Delay(100);
return new Result<string>.Success(
$"Order {order.Id} processed: {finalAmount:C}");
};
}
}
// Usage
var processOrder = OrderProcessor.CreateProcessOrderCommand(0.15m, logger);
var result = await processOrder(new Order(1, 100m, "Alice"));
The function closes over discount and logger but keeps Order immutable. Behavior is deterministic — mock the inputs, predict the outputs.
4. Event Sourcing with Async Streams
Captures state as a sequence of events. Current state is reconstructed by replaying events. .NET 10's IAsyncEnumerable enables efficient async streaming of event history.
public record UserEvent(string Type, int UserId, string Data, DateTime Timestamp);
public record UserState(string Name = "", bool IsActive = false);
public class UserEventStore
{
private readonly List<UserEvent> _events = [];
public async Task AppendEventAsync(UserEvent evt)
{
await Task.Delay(10);
_events.Add(evt);
}
public async IAsyncEnumerable<UserEvent> GetEventsAsync(int userId)
{
foreach (var evt in _events.Where(e => e.UserId == userId)
.OrderBy(e => e.Timestamp))
{
await Task.Delay(10);
yield return evt;
}
}
public async Task<UserState> RebuildStateAsync(int userId)
{
var state = new UserState();
await foreach (var evt in GetEventsAsync(userId))
{
state = evt.Type switch
{
"UserCreated" => state with { Name = evt.Data, IsActive = true },
"UserDeactivated" => state with { IsActive = false },
_ => state
};
}
return state;
}
}
Full audit trail built in. Add new event types without changing existing logic. Integrate with Kafka or EventStoreDB for production.
5. Dependency Injection Scoping Pattern
Manages service lifetimes correctly — transient, scoped, or singleton — preventing memory leaks and concurrency bugs.
public interface IOperationService
{
string GetOperationId();
Task ProcessAsync();
}
public class OperationService : IOperationService
{
private readonly Guid _operationId = Guid.NewGuid();
public string GetOperationId() => _operationId.ToString();
public async Task ProcessAsync()
{
await Task.Delay(100);
Console.WriteLine($"Processing: {_operationId}");
}
}
// Registration
services.AddScoped<IOperationService, OperationService>();
// Usage — each scope gets a unique instance
async Task ProcessRequestAsync(IServiceProvider provider, int requestId)
{
using var scope = provider.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<IOperationService>();
await service.ProcessAsync();
Console.WriteLine($"Request {requestId}: {service.GetOperationId()}");
}
Each CreateScope() call produces a fresh OperationService. The using declaration ensures disposal. Critical for DbContext, unit-of-work, and any stateful per-request service.
6. Strategy Pattern
Encapsulates interchangeable algorithms behind a common interface — swap at runtime without touching callers.
public interface ISortingStrategy
{
void Sort<T>(List<T> data) where T : IComparable<T>;
}
public class BubbleSortStrategy : ISortingStrategy
{
public void Sort<T>(List<T> data) where T : IComparable<T>
{
for (int i = 0; i < data.Count - 1; i++)
for (int j = 0; j < data.Count - i - 1; j++)
if (data[j].CompareTo(data[j + 1]) > 0)
(data[j], data[j + 1]) = (data[j + 1], data[j]); // C# 12 tuple swap
}
}
public class QuickSortStrategy : ISortingStrategy { /* ... */ }
// Usage
ISortingStrategy strategy = new BubbleSortStrategy();
await Task.Run(() => strategy.Sort(data));
strategy = new QuickSortStrategy();
await Task.Run(() => strategy.Sort(data));
Add a MergeSortStrategy without touching any caller code — Open/Closed Principle in practice.
7. Observer Pattern
Notifies subscribers of state changes without tight coupling — essential for event-driven systems, dashboards, and real-time feeds.
public class NewsAgency
{
private readonly List<IObserver<string>> _observers = [];
public void Subscribe(IObserver<string> observer) => _observers.Add(observer);
public void Unsubscribe(IObserver<string> observer) => _observers.Remove(observer);
public async Task PublishNewsAsync(string news)
{
await Task.Delay(100);
foreach (var observer in _observers.ToList())
{
try { observer.OnNext(news); }
catch (Exception ex) { observer.OnError(ex); }
}
}
}
public class NewsSubscriber : IObserver<string>
{
private readonly string _name;
public NewsSubscriber(string name) => _name = name;
public void OnNext(string value) => Console.WriteLine($"{_name}: {value}");
public void OnError(Exception error) => Console.WriteLine($"{_name} error: {error.Message}");
public void OnCompleted() => Console.WriteLine($"{_name} completed");
}
ToList() before iterating prevents modification-during-enumeration bugs. Individual subscriber failures don't crash the publisher.
8. Decorator Pattern
Wraps objects to add responsibilities at runtime — cross-cutting concerns like logging, encryption, or caching without modifying the original class.
public interface IStream
{
Task WriteAsync(string data);
}
public class BasicStream : IStream
{
public async Task WriteAsync(string data)
{
await Task.Delay(100);
Console.WriteLine($"BasicStream wrote: {data}");
}
}
public class EncryptedStream(IStream inner) : IStream
{
public async Task WriteAsync(string data)
{
var encrypted = Convert.ToBase64String(
System.Text.Encoding.UTF8.GetBytes(data));
await inner.WriteAsync(encrypted);
}
}
public class LoggingStream(IStream inner, ILogger<LoggingStream> logger) : IStream
{
public async Task WriteAsync(string data)
{
logger.LogInformation("Writing: {Data}", data);
await inner.WriteAsync(data);
logger.LogInformation("Write complete");
}
}
// Stack decorators
IStream stream = new LoggingStream(
new EncryptedStream(new BasicStream()), logger);
await stream.WriteAsync("Sensitive Data");
Add compression, validation, or retry decorators by wrapping further — no changes to existing classes.
9. Factory Method Pattern
Delegates object creation to subclasses — centralises creation logic, enables extension without modification.
public abstract class Vehicle
{
public abstract string GetTypeName();
public virtual async Task InitializeAsync()
{
await Task.Delay(100);
Console.WriteLine($"{GetTypeName()} initialized");
}
}
public class Car : Vehicle { public override string GetTypeName() => "Car"; }
public class Bike : Vehicle { public override string GetTypeName() => "Bike"; }
public abstract class VehicleFactory
{
public abstract Vehicle CreateVehicle();
}
public class CarFactory : VehicleFactory
{
public override Vehicle CreateVehicle() => new Car();
}
// Usage
VehicleFactory factory = new CarFactory();
var vehicle = factory.CreateVehicle();
await vehicle.InitializeAsync();
Add TruckFactory without touching existing code. Pairs naturally with DI registration for factory selection at runtime.
10. Singleton Pattern
One instance, globally accessible — use .NET's DI container rather than static fields for thread safety and testability.
public class ConfigurationService
{
private static readonly Lazy<ConfigurationService> _instance =
new(() => new ConfigurationService());
public static ConfigurationService Instance => _instance.Value;
private readonly Dictionary<string, string> _settings = new()
{
{ "ApiKey", "12345-abcde" },
{ "Timeout", "30" }
};
private ConfigurationService() { }
public string GetConfig(string key) =>
_settings.TryGetValue(key, out var value) ? value : "Not found";
public async Task SaveConfigAsync(string key, string value)
{
await Task.Delay(100);
_settings[key] = value;
}
}
// Register via DI
services.AddSingleton<ConfigurationService>(_ => ConfigurationService.Instance);
Lazy<T> ensures thread-safe initialisation without explicit locking. Registering via DI keeps it testable — inject a mock in tests rather than hitting the static instance.
Summary
| Pattern | Best for |
|---|---|
| Minimal API Pipeline | Grouping endpoints with shared config |
| Result | Explicit, exception-free error handling |
| Functional Command | Pure, testable business logic |
| Event Sourcing + Async Streams | Audit trails, state reconstruction |
| DI Scoping | Correct service lifetime management |
| Strategy | Swappable algorithms at runtime |
| Observer | Decoupled event notifications |
| Decorator | Composable cross-cutting concerns |
| Factory Method | Centralised, extensible object creation |
| Singleton | Shared resources with thread-safe init |
Start with the patterns that address your current pain points. Test in isolated feature branches before rolling out across a codebase.
Originally published on Medium.
Top comments (0)