Polymorphic Processor Pattern in .NET with Keyed Services
After a decade of working with enterprise systems, I’ve noticed a recurring architectural pain point: elegantly processing different domain objects through a common interface without sacrificing maintainability. At its core, the problem is a tug-of-war between four critical concerns:
Separation of Concerns
Domain models should remain pure, unburdened by infrastructure details, yet business logic needs a home.
Extensibility
The system must accommodate new domain types gracefully, but not at the expense of compile-time safety.
Testability
Logic should be isolatable for testing, but complex dispatching mechanisms often create friction.
Performance
Processing pipelines should not become bottlenecks, yet abstractions inevitably introduce overhead.
TL;DR
Use the Polymorphic Processor Pattern when you need to process several domain object types through a common entry point, but each type requires its own business logic.
Instead of centralizing type checks in a large `switch` statement or forcing every domain model into the Visitor Pattern, register one processor per domain type and let a dispatcher resolve the right processor at runtime.
This works especially well in .NET applications that already use dependency injection, keyed services, and clean separation between domain models and application logic.
Why Traditional Solutions Fall Short
In practice, most teams gravitate toward familiar patterns that eventually reveal glaring limitations. Take the switch-statement anti-pattern – a maintenance time bomb masquerading as straightforward logic:
public async Task ProcessOrder(IOrder order) {
switch (order) {
case PhysicalOrder o: await _shippingService.Ship(o); break;
case DigitalOrder o: await _licenseService.Generate(o); break;
default: throw new NotSupportedException(); // New order type? Enjoy refactoring.
}
}
Then there’s the Visitor Pattern, which exchanges one problem for another. At first glance, it promises clean separation – domain objects stay pristine while processing logic lives in visitor implementations. But look closer, and the cracks appear:
public class PhysicalOrder : IOrder {
public void Accept(IOrderVisitor visitor) => visitor.Visit(this);
}
The classic visitor suggests every domain type to declare its own Accept method, and, more critically, every visitor must know about every concrete class it handles.
public interface IOrderVisitor {
void Visit(PhysicalOrder order); // Tight coupling to concrete types
void Visit(DigitalOrder order); // What happens when SubscriptionOrder arrives?
// ...and so on, ad infinitum
}
In essence, the visitor pattern doesn’t eliminate coupling – it just moves it to a different layer, trading one kind of rigidity for another.
| Approach | What it optimizes for | Where it starts to hurt | Best fit |
|---|---|---|---|
switch / pattern matching | Simplicity and explicit control flow | Centralized type checks grow with every new domain type | Small, stable domains with only a few cases |
| Visitor Pattern | Double dispatch and compile-time coverage | Every visitor must know about every concrete type | Closed object hierarchies where operations change more often than types |
| Strategy Pattern | Swappable behavior behind an interface | Selection logic often moves elsewhere | Choosing one implementation from a known set |
| Keyed services | Runtime selection through DI | Can become service-locator-like if used everywhere | Infrastructure-level variation and controlled runtime resolution |
| Polymorphic Processor Pattern | Type-specific processing with isolated business logic | Adds indirection and requires disciplined registration | Medium-to-large .NET systems with many processable domain types |
The Polymorphic Processor Pattern
The Polymorphic Processor Pattern elegantly solves the problem of type-specific domain processing by combining three key ingredients: a clean separation between data models and business logic, compile-time type safety through generic processors, and runtime flexibility powered by dependency injection. The pattern organizes itself into six clear components – domain models, processor contracts to define behavior, base classes to enforce type constraints, concrete implementations for business logic, DI registration to wire everything together, and a dispatcher to orchestrate execution – all working in concert to create a system where new domain types can be added without modifying core infrastructure, type mismatches fail fast, and processing logic remains isolated and testable. Here’s how it all fits together in code:
Domain models
// Core interface
public interface IOrder { OrderId Id { get; } }
// Data models
public record PhysicalOrder(OrderId Id, ShippingAddress Address) : IOrder;
public record DigitalOrder(OrderId Id, EmailAddress Recipient) : IOrder;
Processor contract
// A single responsibility interface - transform orders into results
public interface IOrderProcessor
{
Task<ProcessResult> ProcessAsync(IOrder order);
}
Type-safe base processor
public abstract class OrderProcessorBase<TOrder> : IOrderProcessor
where TOrder : class, IOrder
{
public async Task<ProcessResult> ProcessAsync(IOrder order)
{
// Safe type conversion
if (order is TOrder specificOrder)
return await ProcessAsync(specificOrder);
throw new InvalidOperationException(
$"Expected {typeof(TOrder).Name}, got {order.GetType().Name}");
}
// Concrete processors implement this
protected abstract Task<ProcessResult> ProcessAsync(TOrder order);
}
Concrete processor example
public class ShippingProcessor : OrderProcessorBase<PhysicalOrder>
{
private readonly IShippingService _shipping;
protected override async Task<ProcessResult> ProcessAsync(PhysicalOrder order)
{
// Use shipping service to ship the order
...
}
}
DI registration
// Specific processors
services.AddKeyedScoped<IOrderProcessor, ShippingProcessor>(typeof(PhysicalOrder));
services.AddKeyedScoped<IOrderProcessor, LicenseProcessor>(typeof(DigitalOrder));
// Fallback processor
services.AddKeyedScoped<IOrderProcessor, UnknownOrderProcessor>(typeof(IOrder));
The key idea is that the service key represents the domain type being processed, not just a named implementation. This is the same broader direction I explore in keyed services as pipelines, not just pick-one DI, where keyed registration becomes a composition mechanism rather than a simple replacement for if statements.
Dispatcher implementation
public sealed class OrderDispatcher
{
private readonly IServiceProvider _provider;
public async Task<ProcessResult> ProcessAsync(IOrder order)
{
// Try specific processor
var processor = _provider.GetKeyedService<IOrderProcessor>(
order.GetType());
// Fallback to default if needed
processor ??= _provider.GetKeyedService<IOrderProcessor>(
typeof(IOrder));
return processor != null
? await processor.ProcessAsync(order)
: throw new InvalidOperationException(
$"No processor registered for {order.GetType().Name}");
}
}
Potential optimizations
Caching for Performance
The basic dispatcher resolves the appropriate processor on every call. For most application workloads, that overhead is small compared to the business operation itself, but in high-throughput scenarios it may still be worth avoiding repeated keyed-service lookups.
A simple optimization is to cache the resolved processor per domain type. This keeps the dispatch path fast while preserving the same type-based routing model:
using System.Collections.Concurrent;
using Microsoft.Extensions.DependencyInjection;
public sealed class CachedOrderDispatcher
{
private readonly IServiceProvider _serviceProvider;
private readonly ConcurrentDictionary<Type, IOrderProcessor> _processorCache = new();
public CachedOrderDispatcher(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task<ProcessResult> ProcessAsync(IOrder order)
{
ArgumentNullException.ThrowIfNull(order);
var processor = _processorCache.GetOrAdd(
order.GetType(),
ResolveProcessor);
return await processor.ProcessAsync(order);
}
private IOrderProcessor ResolveProcessor(Type orderType)
{
return _serviceProvider.GetKeyedService<IOrderProcessor>(orderType)
?? _serviceProvider.GetKeyedService<IOrderProcessor>(typeof(IOrder))
?? throw new InvalidOperationException(
$"No processor registered for {orderType.Name}");
}
}
This optimization is only safe when the dispatcher lifetime is compatible with the processor lifetime. For example, if processors are registered as scoped, register the cached dispatcher as scoped as well:
services.AddScoped<CachedOrderDispatcher>();
services.AddKeyedScoped<IOrderProcessor, ShippingProcessor>(typeof(PhysicalOrder));
services.AddKeyedScoped<IOrderProcessor, LicenseProcessor>(typeof(DigitalOrder));
services.AddKeyedScoped<IOrderProcessor, UnknownOrderProcessor>(typeof(IOrder));
Do not register CachedOrderDispatcher as a singleton while caching scoped or transient processors. In that case, either avoid caching resolved processor instances or cache only lookup metadata.
When to Use This Pattern
Ideal Use Cases
- Systems processing multiple domain object types
- Codebases using dependency injection extensively
- Teams practicing vertical slice architecture
- Applications requiring runtime-discoverable processors
Less Suitable For
- Extremely performance-sensitive hot paths
- Simple domains with only 2-3 fixed types
- Systems where all processing fits naturally in domain objects
Conclusion
The Polymorphic Processor Pattern represents a sophisticated yet practical approach to handling type-specific domain processing in modern .NET applications. By understanding and applying this pattern judiciously, teams can build systems that remain adaptable as business requirements evolve, while maintaining the rigor of type safety and clean architecture.

RSS - Posts