The Polymorphic Processor Pattern

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.

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.

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));

Dispatcher implementation

public sealed class OrderDispatcher
{
    private readonly IServiceProvider _provider;

    public async Task<OrderResult> 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

public class CachedOrderDispatcher
{
    private readonly ConcurrentDictionary _processorCache = new();
    private readonly IServiceProvider _serviceProvider;

    public async Task<OrderResult> ProcessAsync(IOrder order)
    {
        var processor = _processorCache.GetOrAdd(order.GetType(), type =>
            _serviceProvider.GetKeyedService(type) 
            ?? _serviceProvider.GetKeyedService(typeof(IOrder)));
            
        return await processor.ProcessAsync(order);
    }
}

Critical Notes on Caching: The lifetime of cached processors must align with their dispatcher’s lifetime scope:

  1. Singleton Dispatcher – All processors must be singletons
    • Prevents memory leaks from captured dependencies
    • Ensures thread safety for the cache dictionary
  2. Scoped Dispatcher – Processors can be either singletons or scoped
    • Scoped processors work when cache lives within one scope
    • Still avoids transient processors (risk of captive dependencies)
  3. Transient Dispatcher – Not recommended with caching
    • Cache would recreate with each dispatcher instance
    • Defeats the purpose of caching entirely

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.

Share

You may also like...

Leave a Reply

Your email address will not be published. Required fields are marked *