Design Patterns Guide
This guide explains the key design patterns used in SimpleMediator, their purpose, implementation details, and how they work together to create a robust, maintainable mediator framework.
Table of Contents
- Mediator Pattern
- Railway Oriented Programming
- Chain of Responsibility
- Decorator Pattern
- Observer Pattern
- Factory Pattern
- Dependency Injection
- Expression Tree Compilation
- Guard Clauses
- Pattern Interactions
Mediator Pattern
Purpose
Reduce coupling between components by introducing a central coordinator that handles communication between objects without them knowing about each other.
Problem
Without a mediator:
// Controller tightly coupled to business logic
public class UserController
{
private readonly UserRepository _userRepo;
private readonly EmailService _emailService;
private readonly AuditLogger _auditLogger;
private readonly CacheService _cache;
public async Task<User> CreateUser(CreateUserRequest request)
{
// Validation logic
if (string.IsNullOrEmpty(request.Email)) throw new ValidationException();
// Business logic
var user = await _userRepo.Create(request);
// Side effects
await _emailService.SendWelcomeEmail(user);
await _auditLogger.Log("UserCreated", user.Id);
await _cache.Invalidate("users");
return user;
}
}
Issues:
- Controller knows about 4 different services
- Hard to test (must mock all dependencies)
- Cross-cutting concerns (validation, caching, logging) scattered across controllers
- Difficult to add new functionality without modifying existing code
Solution with Mediator
// Controller only depends on IMediator
public class UserController
{
private readonly IMediator _mediator;
public async Task<IActionResult> CreateUser(CreateUserRequest request)
{
var result = await _mediator.Send(new CreateUserCommand(request.Email, request.Name));
return result.Match(
Right: user => Ok(user),
Left: error => BadRequest(error.Message)
);
}
}
// Business logic isolated in handler
public class CreateUserHandler : IRequestHandler<CreateUserCommand, User>
{
private readonly UserRepository _userRepo;
public async Task<User> Handle(CreateUserCommand request, CancellationToken ct)
{
var user = await _userRepo.Create(request.Email, request.Name);
return user;
}
}
// Cross-cutting concerns in behaviors
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
public async ValueTask<Either<MediatorError, TResponse>> Handle(
TRequest request, RequestHandlerCallback<TResponse> next, CancellationToken ct)
{
// Validation logic applies to ALL requests
if (request is IValidatable validatable && !validatable.IsValid())
return Left(MediatorErrors.ValidationFailed);
return await next();
}
}
Benefits
- Single Responsibility: Controller only coordinates, handler only contains business logic
- Open/Closed Principle: Add new commands without modifying existing code
- Testability: Easy to test handlers in isolation
- Consistency: All requests flow through the same pipeline
Implementation in SimpleMediator
public sealed partial class SimpleMediator : IMediator
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<SimpleMediator> _logger;
public ValueTask<Either<MediatorError, TResponse>> Send<TResponse>(
IRequest<TResponse> request, CancellationToken ct = default)
{
if (!MediatorRequestGuards.TryValidateRequest<TResponse>(request, out var error))
return new ValueTask<Either<MediatorError, TResponse>>(error);
return new ValueTask<Either<MediatorError, TResponse>>(
RequestDispatcher.ExecuteAsync(this, request, ct));
}
}
Railway Oriented Programming
Purpose
Make error handling explicit in the type system and enable composable error handling without exceptions.
Problem
Traditional exception-based error handling:
public async Task<User> GetUser(int id)
{
var user = await _userRepo.FindById(id);
if (user == null) throw new NotFoundException("User not found");
if (!user.IsActive) throw new InvalidOperationException("User inactive");
return user;
}
// Caller has no idea this can throw exceptions (invisible in signature)
var user = await GetUser(123); // May throw - not visible in code
Issues:
- Exceptions are invisible in method signatures
- Expensive (stack unwinding, allocation)
- Control flow is implicit (hard to follow)
- Difficult to compose error-handling logic
Solution with Railway Oriented Programming
public async Task<Either<MediatorError, User>> GetUser(int id)
{
var user = await _userRepo.FindById(id);
if (user is null)
return Left(MediatorErrors.NotFound("User not found"));
if (!user.IsActive)
return Left(MediatorErrors.InvalidOperation("User inactive"));
return Right(user);
}
// Caller knows this returns Either - errors are explicit
var result = await GetUser(123);
result.Match(
Right: user => Console.WriteLine($"Found: {user.Name}"),
Left: error => Console.WriteLine($"Error: {error.Message}")
);
The Two Tracks
Happy Path (Right): Request → Validate → Process → Transform → Response
↓ ↓ ↓
Error Path (Left): Error ← Error ← Error
Key Principle: Once you enter the Left track, you stay on the Left track (short-circuiting).
Implementation in SimpleMediator
// Pipeline execution with short-circuiting
private static async ValueTask<Either<MediatorError, TResponse>> ExecutePipelineAsync(...)
{
// Pre-processors
foreach (var preProcessor in preProcessors)
{
var failure = await ExecutePreProcessorAsync(preProcessor, request, ct);
if (failure.IsSome)
{
// Short-circuit: Return error immediately
return Left<MediatorError, TResponse>(failure.Match(err => err, () => MediatorErrors.Unknown));
}
}
// Execute handler (may return Left or Right)
var response = await terminal();
// Post-processors only run if response is Right
foreach (var postProcessor in postProcessors)
{
var failure = await ExecutePostProcessorAsync(postProcessor, request, response, ct);
if (failure.IsSome)
{
return Left<MediatorError, TResponse>(/* error */);
}
}
return response; // Either Left or Right
}
Benefits
- Explicit Errors: Type system forces you to handle errors
- Composability: Chain operations with
Bind,Map,Match - Performance: No exception throwing for expected failures
- Functional Style: Works well with LINQ and functional programming
Combinators
// Map: Transform the Right value
var result = await mediator.Send(new GetUserQuery(1));
var nameResult = result.Map(user => user.Name); // Either<MediatorError, string>
// Bind: Chain operations that return Either
var result = await mediator.Send(new GetUserQuery(1));
var ordersResult = result.Bind(user => mediator.Send(new GetOrdersQuery(user.Id)));
// Match: Extract value with exhaustive pattern matching
result.Match(
Right: user => $"Hello, {user.Name}",
Left: error => $"Error: {error.Message}"
);
Chain of Responsibility
Purpose
Pass a request through a chain of handlers, where each handler can either process the request or pass it to the next handler.
Problem
Cross-cutting concerns (logging, validation, caching) duplicated across handlers:
public class CreateUserHandler : IRequestHandler<CreateUserCommand, User>
{
public async Task<User> Handle(CreateUserCommand request, CancellationToken ct)
{
// Logging (duplicated in every handler)
_logger.LogInformation("Creating user: {Email}", request.Email);
// Validation (duplicated in every handler)
if (!IsValid(request)) throw new ValidationException();
// Actual logic
var user = await _userRepo.Create(request);
// Logging again (duplicated in every handler)
_logger.LogInformation("User created: {UserId}", user.Id);
return user;
}
}
Solution with Chain of Responsibility
// Each behavior wraps the next step in the chain
public class LoggingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
private readonly ILogger _logger;
public async ValueTask<Either<MediatorError, TResponse>> Handle(
TRequest request, RequestHandlerCallback<TResponse> next, CancellationToken ct)
{
_logger.LogInformation("Processing {RequestType}", typeof(TRequest).Name);
var result = await next(); // Call next step in chain
result.Match(
Right: _ => _logger.LogInformation("Success"),
Left: err => _logger.LogError("Failed: {Error}", err.Message)
);
return result;
}
}
public class ValidationBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
{
public async ValueTask<Either<MediatorError, TResponse>> Handle(
TRequest request, RequestHandlerCallback<TResponse> next, CancellationToken ct)
{
if (request is IValidatable validatable && !validatable.IsValid())
return Left(MediatorErrors.ValidationFailed); // Short-circuit
return await next(); // Continue chain
}
}
// Handler is now pure business logic (no cross-cutting concerns)
public class CreateUserHandler : IRequestHandler<CreateUserCommand, User>
{
public async Task<User> Handle(CreateUserCommand request, CancellationToken ct)
{
return await _userRepo.Create(request);
}
}
Chain Execution Order
Request
↓
LoggingBehavior.Before
↓
ValidationBehavior.Before
↓
CachingBehavior.Before
↓
Handler.Handle
↓
CachingBehavior.After
↓
ValidationBehavior.After
↓
LoggingBehavior.After
↓
Response
Implementation in SimpleMediator
// PipelineBuilder creates nested delegates (Russian doll pattern)
public RequestHandlerCallback<TResponse> Build(IServiceProvider serviceProvider)
{
// Start with innermost: handler
RequestHandlerCallback<TResponse> current = () => ExecuteHandlerAsync(_handler, _request, _ct);
// Wrap handler with behaviors in REVERSE order
// (to execute in registration order)
if (behaviors.Length > 0)
{
for (var index = behaviors.Length - 1; index >= 0; index--)
{
var behavior = behaviors[index];
var nextStep = current; // Capture in closure
current = () => ExecuteBehaviorAsync(behavior, _request, nextStep, _ct);
}
}
// Outer layer: pre/post processors
return () => ExecutePipelineAsync(preProcessors, postProcessors, current, _request, _ct);
}
Benefits
- Separation of Concerns: Each behavior handles one responsibility
- Reusability: Behaviors apply to all requests automatically
- Flexibility: Add/remove behaviors via DI registration
- Testability: Test behaviors independently
Decorator Pattern
Purpose
Dynamically add responsibilities to objects by wrapping them in decorator objects.
Problem
Want to add functionality (caching, logging, metrics) to handlers without modifying them.
Solution
Pipeline behaviors are decorators around the handler:
// Base handler
public class GetUserHandler : IRequestHandler<GetUserQuery, User>
{
public async Task<User> Handle(GetUserQuery request, CancellationToken ct)
{
return await _userRepo.FindById(request.UserId);
}
}
// Caching decorator
public class CachingBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
private readonly ICache _cache;
public async ValueTask<Either<MediatorError, TResponse>> Handle(
TRequest request, RequestHandlerCallback<TResponse> next, CancellationToken ct)
{
var key = $"{typeof(TRequest).Name}:{GetCacheKey(request)}";
// Try cache
if (_cache.TryGet<TResponse>(key, out var cached))
return Right(cached);
// Call wrapped handler
var result = await next();
// Cache successful result
result.IfRight(value => _cache.Set(key, value, TimeSpan.FromMinutes(5)));
return result;
}
}
Execution Flow:
CachingBehavior.Handle
↓ (calls next)
LoggingBehavior.Handle
↓ (calls next)
ValidationBehavior.Handle
↓ (calls next)
GetUserHandler.Handle (actual logic)
Difference from Chain of Responsibility
- Chain: One handler processes OR passes to next
- Decorator: All decorators execute in sequence, wrapping the core handler
In SimpleMediator, behaviors are both:
- Chain: Can short-circuit by returning Left without calling
next() - Decorator: Wrap the handler and add functionality before/after
Observer Pattern
Purpose
Define a one-to-many dependency so that when one object changes state, all dependents are notified.
Problem
Multiple components need to react to the same event:
// Tightly coupled event handling
public async Task CreateOrder(Order order)
{
await _orderRepo.Save(order);
// Manual notification of all interested parties
await _emailService.SendOrderConfirmation(order);
await _inventoryService.DecrementStock(order.Items);
await _analyticsService.TrackOrderCreated(order);
await _auditLogger.Log("OrderCreated", order.Id);
}
Solution with Observer Pattern
// Publisher: Raise notification
public class CreateOrderHandler : IRequestHandler<CreateOrderCommand, Order>
{
private readonly IMediator _mediator;
public async Task<Order> Handle(CreateOrderCommand request, CancellationToken ct)
{
var order = await _orderRepo.Save(request);
// Publish notification (decoupled)
await _mediator.Publish(new OrderCreatedNotification(order), ct);
return order;
}
}
// Observers: React to notification
public class SendOrderConfirmationHandler : INotificationHandler<OrderCreatedNotification>
{
public async Task Handle(OrderCreatedNotification notification, CancellationToken ct)
{
await _emailService.SendOrderConfirmation(notification.Order);
}
}
public class DecrementStockHandler : INotificationHandler<OrderCreatedNotification>
{
public async Task Handle(OrderCreatedNotification notification, CancellationToken ct)
{
await _inventoryService.DecrementStock(notification.Order.Items);
}
}
public class TrackOrderAnalyticsHandler : INotificationHandler<OrderCreatedNotification>
{
public async Task Handle(OrderCreatedNotification notification, CancellationToken ct)
{
await _analyticsService.TrackOrderCreated(notification.Order);
}
}
Implementation in SimpleMediator
// NotificationDispatcher broadcasts to all handlers
public static async Task<Either<MediatorError, Unit>> ExecuteAsync<TNotification>(
SimpleMediator mediator, TNotification notification, CancellationToken ct)
where TNotification : INotification
{
var notificationType = notification?.GetType() ?? typeof(TNotification);
var handlerType = typeof(INotificationHandler<>).MakeGenericType(notificationType);
// Resolve ALL handlers (0 or more)
var handlers = serviceProvider.GetServices(handlerType).ToList();
if (handlers.Count == 0)
return Right<MediatorError, Unit>(Unit.Default); // No handlers is OK
// Execute each handler sequentially
foreach (var handler in handlers)
{
var result = await InvokeNotificationHandler(handler, notification, ct);
if (result.IsLeft) return result; // Fail-fast on first error
}
return Right<MediatorError, Unit>(Unit.Default);
}
Benefits
- Decoupling: Publisher doesn't know about observers
- Extensibility: Add new observers without modifying publisher
- Single Responsibility: Each observer handles one concern
Factory Pattern
Purpose
Encapsulate object creation logic and provide a common interface for creating families of related objects.
Implementation in SimpleMediator
// Abstract factory interface
internal interface IRequestHandlerWrapper
{
Type HandlerServiceType { get; }
object? ResolveHandler(IServiceProvider serviceProvider);
Task<object> Handle(SimpleMediator mediator, object request, object handler,
IServiceProvider serviceProvider, CancellationToken ct);
}
// Concrete factory for specific request/response types
internal sealed class RequestHandlerWrapper<TRequest, TResponse> : IRequestHandlerWrapper
where TRequest : IRequest<TResponse>
{
public Type HandlerServiceType => typeof(IRequestHandler<TRequest, TResponse>);
public object? ResolveHandler(IServiceProvider serviceProvider)
=> serviceProvider.GetService<IRequestHandler<TRequest, TResponse>>();
public async Task<object> Handle(SimpleMediator mediator, object request, object handler,
IServiceProvider serviceProvider, CancellationToken ct)
{
var typedRequest = (TRequest)request;
var typedHandler = (IRequestHandler<TRequest, TResponse>)handler;
// Build and execute pipeline
var builder = new PipelineBuilder<TRequest, TResponse>(typedRequest, typedHandler, ct);
var pipeline = builder.Build(serviceProvider);
return await pipeline();
}
}
// Factory method
private static IRequestHandlerWrapper CreateRequestHandlerWrapper(Type requestType, Type responseType)
{
var wrapperType = typeof(RequestHandlerWrapper<,>).MakeGenericType(requestType, responseType);
return (IRequestHandlerWrapper)Activator.CreateInstance(wrapperType)!;
}
Benefits
- Type Erasure: Hide generic type parameters behind non-generic interface
- Caching: Factory objects are cached and reused
- Testability: Easy to mock factory interface
Guard Clauses
Purpose
Validate preconditions early and fail fast with clear error messages.
Problem
Validation logic scattered throughout methods:
public async Task<Either<MediatorError, TResponse>> Send<TResponse>(
IRequest<TResponse> request, CancellationToken ct)
{
if (request is null)
{
var message = "The request cannot be null.";
var error = MediatorErrors.Create(MediatorErrorCodes.RequestNull, message);
return Left<MediatorError, TResponse>(error);
}
// Duplicate validation logic in every method
}
Solution with Guard Clauses
// Centralized guard clauses
internal static class MediatorRequestGuards
{
public static bool TryValidateRequest<TResponse>(
object? request, out Either<MediatorError, TResponse> error)
{
if (request is not null)
{
error = default;
return true;
}
const string message = "The request cannot be null.";
error = Left<MediatorError, TResponse>(
MediatorErrors.Create(MediatorErrorCodes.RequestNull, message));
return false;
}
public static bool TryValidateHandler<TResponse>(
object? handler, Type requestType, Type responseType,
out Either<MediatorError, TResponse> error)
{
if (handler is not null)
{
error = default;
return true;
}
var message = $"No registered IRequestHandler was found for {requestType.Name} -> {responseType.Name}.";
var metadata = new Dictionary<string, object?>
{
["requestType"] = requestType.FullName,
["responseType"] = responseType.FullName,
["stage"] = "handler_resolution"
};
error = Left<MediatorError, TResponse>(
MediatorErrors.Create(MediatorErrorCodes.RequestHandlerMissing, message, details: metadata));
return false;
}
}
// Usage
public ValueTask<Either<MediatorError, TResponse>> Send<TResponse>(
IRequest<TResponse> request, CancellationToken ct = default)
{
if (!MediatorRequestGuards.TryValidateRequest<TResponse>(request, out var error))
return new ValueTask<Either<MediatorError, TResponse>>(error);
return new ValueTask<Either<MediatorError, TResponse>>(
RequestDispatcher.ExecuteAsync(this, request, ct));
}
Benefits
- DRY: Validation logic in one place
- Consistency: Same error messages and codes everywhere
- Testability: Easy to test validation logic in isolation
- Fail Fast: Errors detected early in the pipeline
Expression Tree Compilation
Purpose
Generate optimized code at runtime to avoid reflection overhead.
Problem
Reflection is slow (~50-100x slower than direct calls):
// Slow: Reflection invocation
var method = handler.GetType().GetMethod("Handle");
var result = (Task)method.Invoke(handler, new[] { notification, cancellationToken });
await result;
Solution with Expression Trees
// Compile once, reuse forever
private static Func<object, object?, CancellationToken, Task> CreateNotificationInvoker(
MethodInfo method, Type handlerType, Type notificationType)
{
// Create lambda parameters
var handlerParam = Expression.Parameter(typeof(object), "handler");
var notificationParam = Expression.Parameter(typeof(object), "notification");
var ctParam = Expression.Parameter(typeof(CancellationToken), "cancellationToken");
// Cast object → concrete types
var castHandler = Expression.Convert(handlerParam, handlerType);
var castNotification = Expression.Convert(notificationParam, notificationType);
// Generate method call: handler.Handle(notification, ct)
var call = Expression.Call(castHandler, method, castNotification, ctParam);
// Compile to delegate
var lambda = Expression.Lambda<Func<object, object?, CancellationToken, Task>>(
call, handlerParam, notificationParam, ctParam);
return lambda.Compile();
}
// Usage (cached)
var invoker = NotificationHandlerInvokerCache.GetOrAdd(
(handlerType, notificationType),
_ => CreateNotificationInvoker(method, handlerType, notificationType));
// Fast: Compiled delegate (near-native speed)
var result = invoker(handler, notification, cancellationToken);
await result;
Performance Comparison
| Approach | Time | Ratio |
|---|---|---|
| Direct call | 150ns | 1.0x |
| Compiled delegate | 180ns | 1.2x |
| MethodInfo.Invoke | 2,500ns | 16.7x |
Benefits
- Performance: 50-100x faster than reflection
- Type Safety: Casts are validated at compile time
- Caching: Compiled once, reused forever
Pattern Interactions
How Patterns Work Together
┌─────────────────────────────────────────────────────────────┐
│ Mediator Pattern │
│ (SimpleMediator coordinates all communication) │
└────────────┬────────────────────────────────────────────────┘
│
├─► Railway Oriented Programming
│ (All operations return Either<Error, Success>)
│
├─► Dependency Injection
│ (Resolve handlers and behaviors from DI)
│
├─► Factory Pattern
│ (RequestHandlerWrapper creates pipeline)
│
└─► Chain of Responsibility + Decorator
(Behaviors wrap handler in nested chain)
│
├─► Guard Clauses
│ (Validate inputs early)
│
├─► Observer Pattern
│ (Notifications broadcast to handlers)
│
└─► Expression Tree Compilation
(Fast handler invocation)
Example: Complete Request Flow
// 1. Mediator Pattern: Client sends request through mediator
var result = await mediator.Send(new GetUserQuery(123));
// 2. Guard Clauses: Validate request not null
if (!MediatorRequestGuards.TryValidateRequest(request, out var error))
return error;
// 3. Factory Pattern: Get cached wrapper
var wrapper = RequestHandlerCache.GetOrAdd((requestType, responseType), CreateWrapper);
// 4. Dependency Injection: Resolve handler from DI
var handler = wrapper.ResolveHandler(serviceProvider);
// 5. Chain of Responsibility + Decorator: Build pipeline
var pipeline = pipelineBuilder.Build(serviceProvider);
// Pipeline = PreProc → Logging → Validation → Caching → Handler → PostProc
// 6. Railway Oriented Programming: Execute with short-circuiting
var response = await pipeline(); // Either<MediatorError, User>
// 7. Observer Pattern (optional): Publish notification
await mediator.Publish(new UserRetrievedNotification(user));
// 8. Expression Tree Compilation: Fast notification handler invocation
var invoker = NotificationHandlerInvokerCache.GetOrAdd(key, Compile);
await invoker(handler, notification, ct);
// 9. Back to client with Either result
result.Match(
Right: user => Ok(user),
Left: error => BadRequest(error.Message)
);
Summary
| Pattern | Primary Benefit | Used In |
|---|---|---|
| Mediator | Decoupling | SimpleMediator |
| Railway Oriented Programming | Explicit errors | All async operations |
| Chain of Responsibility | Composable behaviors | Pipeline behaviors |
| Decorator | Dynamic functionality | Pipeline behaviors |
| Observer | Event broadcasting | Notifications |
| Factory | Type erasure & caching | RequestHandlerWrapper |
| Dependency Injection | Loose coupling | All component resolution |
| Expression Trees | Performance | Notification handler invocation |
| Guard Clauses | Early validation | MediatorRequestGuards |