-
Notifications
You must be signed in to change notification settings - Fork 0
04 06 Dispatcher Pattern
TypeCollections can contain behavior (methods), not just data. When TypeOptions implement methods with identical signatures, you can dispatch to any option polymorphically and let C# overload resolution pick the correct implementation based on input type.
This eliminates reflection, provides compile-time type safety, and enables extensible plugin architectures.
Traditional TypeCollections (data only):
[TypeOption(typeof(OrderStatuses), "Pending")]
public sealed class PendingStatus : OrderStatusBase
{
public PendingStatus() : base(1, "Pending", isTerminal: false) { }
// Just data properties - no methods
}Behavioral TypeCollections (data + methods):
[TypeOption(typeof(MsSqlDataCommandTranslators), "Query")]
public sealed class MsSqlQueryTranslator : MsSqlDataCommandTranslatorBase
{
// Common method signature (same across ALL translators)
public override Task<IGenericResult<SqlCommand>> Translate(
IDataCommand command,
IStorageContainer container,
CancellationToken ct) { ... }
// Overload for specific input type
public Task<IGenericResult<SqlCommand>> Translate(
IQueryCommand command,
IStorageContainer container,
CancellationToken ct) { ... }
}All TypeOptions share identical method signatures (same name, same parameters, same return type):
TypeCollection: ConnectionTypes
├─ MsSql
│ └─ Execute(IDataCommand, CancellationToken) → IGenericResult
├─ PostgreSql
│ └─ Execute(IDataCommand, CancellationToken) → IGenericResult
└─ Oracle
└─ Execute(IDataCommand, CancellationToken) → IGenericResult
TypeCollection: MsSqlDataCommandTranslators
├─ Query
│ └─ Translate(IDataCommand, IStorageContainer, CancellationToken) → IGenericResult<SqlCommand>
├─ Insert
│ └─ Translate(IDataCommand, IStorageContainer, CancellationToken) → IGenericResult<SqlCommand>
└─ Update
└─ Translate(IDataCommand, IStorageContainer, CancellationToken) → IGenericResult<SqlCommand>
CRITICAL: Same method NAME, same parameters, same return type across ALL options.
Why this matters: Dispatcher can call translator.Translate(...) on ANY option without knowing which specific option it is. The method signature is guaranteed to exist.
- TypeCollection - Discover all options at compile time
- TypeOptions - Contain behavior with identical signatures
- Base method - Accepts general interface (IDataCommand)
- Overload methods - Accept specific interfaces (IQueryCommand, IFilterableCommand)
- C# overload resolution - Compile-time dispatch to correct overload
TypeCollection: MsSqlDataCommandTranslators
├─ Query (TypeOption)
│ ├─ Translate(IDataCommand) → error
│ └─ Translate(IQueryCommand) → SELECT statement
├─ Update (TypeOption)
│ ├─ Translate(IDataCommand) → error
│ └─ Translate(IFilterableCommand) → UPDATE statement
└─ Delete (TypeOption)
├─ Translate(IDataCommand) → error
└─ Translate(IFilterableCommand) → DELETE statement
All TypeOptions have SAME method signature:
Task<IGenericResult<SqlCommand>> Translate(IDataCommand, IStorageContainer, CancellationToken)
Dispatcher calls ANY option → Overload resolution picks correct method!
// Reflection-based property access (SLOW, UNSAFE)
var commandType = command.GetType();
var filter = commandType.GetProperty("Filter")?.GetValue(command) as IFilterExpression;Step 1: Create Interface
From IQueryCommand.cs:14-45:
public interface IQueryCommand : IDataCommand
{
/// <summary>
/// Gets the filter expression (WHERE clause).
/// </summary>
IFilterExpression? Filter { get; }
/// <summary>
/// Gets the projection expression (SELECT clause).
/// </summary>
IProjectionExpression? Projection { get; }
/// <summary>
/// Gets the ordering expression (ORDER BY clause).
/// </summary>
IOrderingExpression? Ordering { get; }
/// <summary>
/// Gets the paging expression (SKIP/TAKE).
/// </summary>
IPagingExpression? Paging { get; }
/// <summary>
/// Gets the aggregation expression (GROUP BY).
/// </summary>
IAggregationExpression? Aggregation { get; }
/// <summary>
/// Gets the join expressions (JOIN clauses).
/// </summary>
IReadOnlyList<IJoinExpression> Joins { get; }
}Step 2: Implement Interface
From QueryCommand.cs:59-106:
[TypeOption(typeof(DataCommands), "Query")]
public sealed class QueryCommand<T> : DataCommandBase<IEnumerable<T>>, IQueryCommand
{
public QueryCommand(string containerName)
: base("Query", containerName)
{
}
public IFilterExpression? Filter { get; init; }
public IProjectionExpression? Projection { get; init; }
public IOrderingExpression? Ordering { get; init; }
public IPagingExpression? Paging { get; init; }
public IAggregationExpression? Aggregation { get; init; }
public IReadOnlyList<IJoinExpression> Joins { get; init; } = [];
}Step 3: Translator with Overloads
From MsSqlQueryTranslator.cs:38-108:
[TypeOption(typeof(MsSqlDataCommandTranslators), "Query")]
public sealed class MsSqlQueryTranslator : MsSqlDataCommandTranslatorBase
{
public MsSqlQueryTranslator()
: base("Query")
{
}
/// <summary>
/// Base Translate - returns error via MessageLogging for invalid command types.
/// Overload resolution dispatches to Translate(IQueryCommand) for valid commands.
/// </summary>
public override Task<IGenericResult<SqlCommand>> Translate(
IDataCommand command,
IStorageContainer container,
CancellationToken cancellationToken = default)
{
return Task.FromResult(
GenericResult<SqlCommand>.Failure(
MsSqlTranslatorLog.InvalidCommandType(
NullLogger<MsSqlQueryTranslator>.Instance,
"MsSqlQueryTranslator",
"IQueryCommand",
command.GetType().Name)));
}
/// <summary>
/// Translates an IQueryCommand to a T-SQL SELECT statement.
/// Overload resolution ensures this method is called for IQueryCommand instances.
/// </summary>
public Task<IGenericResult<SqlCommand>> Translate(
IQueryCommand command,
IStorageContainer container,
CancellationToken cancellationToken = default)
{
// Build SELECT statement with strongly-typed properties (no reflection!)
var sqlCommand = BuildSelectStatement(
container,
dbPath,
command.Filter,
command.Projection,
command.Ordering,
command.Paging);
return Task.FromResult(GenericResult<SqlCommand>.Success(sqlCommand));
}
}Step 4: Dispatcher Routes via TypeCollection
The connection uses TypeCollection.ByName() for O(1) lookup - NO manual switch statement:
// In ConnectionBase - abstract method for derived classes
protected abstract IDataCommandTranslator<TCommand> GetTranslator(string commandType);
// In MsSqlConnection - uses TypeCollection for dispatch
protected override IDataCommandTranslator<SqlCommand> GetTranslator(string commandType)
=> MsSqlDataCommandTranslators.ByName(commandType);
// Usage in Execute():
var translator = GetTranslator(command.CommandType);
var result = await translator.Translate(command, container, cancellationToken);When you call _queryTranslator.Translate(command, container, ct):
-
C# overload resolution checks if
commandimplementsIQueryCommand -
If YES: Calls
Translate(IQueryCommand, ...)- the fast path -
If NO: Calls
Translate(IDataCommand, ...)- returns error via MessageLogging
Zero runtime type inspection! All resolved at compile time.
Commands expose their type via property (set in constructor).
From DataCommandBase.cs:21-52:
public abstract class DataCommandBase : IDataCommand
{
protected DataCommandBase(string commandType, string containerName, string category = "Data")
{
CommandId = Guid.NewGuid();
CreatedAt = DateTime.UtcNow;
CommandType = commandType ?? throw new ArgumentNullException(nameof(commandType));
ContainerName = containerName ?? throw new ArgumentNullException(nameof(containerName));
Category = category ?? "Data";
Metadata = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
}
public Guid CommandId { get; }
public DateTime CreatedAt { get; }
public string CommandType { get; }
public string Category { get; }
public string ContainerName { get; }
public string ConnectionName { get; init; } = "Default";
public IReadOnlyDictionary<string, object> Metadata { get; init; }
}Routing uses string comparison (fast) instead of GetType().Name.StartsWith() (slow reflection).
Translators calculate deterministic IDs from names using FNV-1a hash.
From DataCommandTranslatorBase.cs:33-61:
protected DataCommandTranslatorBase(string name, string domainName)
{
Id = GenerateIdFromName(name);
Name = name;
DomainName = domainName;
}
/// <summary>
/// Generates a deterministic ID from a translator name using FNV-1a hash.
/// </summary>
private static int GenerateIdFromName(string name)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentNullException(nameof(name));
unchecked
{
const int FnvPrime = 0x01000193;
const int FnvOffsetBasis = (int)0x811C9DC5;
int hash = FnvOffsetBasis;
foreach (char c in name)
{
hash ^= c;
hash *= FnvPrime;
}
return hash & 0x7FFFFFFF;
}
}No manual ID assignment - prevents collisions, ensures consistency.
Translators are discovered via TypeCollection.
From MsSqlDataCommandTranslators.cs:37-49:
[TypeCollection(typeof(MsSqlDataCommandTranslatorBase), typeof(IDataCommandTranslator<SqlCommand>), typeof(MsSqlDataCommandTranslators))]
[ExcludeFromCodeCoverage]
public abstract partial class MsSqlDataCommandTranslators :
TypeCollectionBase<MsSqlDataCommandTranslatorBase, IDataCommandTranslator<SqlCommand>>
{
// Source generator creates:
// - Static constructor
// - Static properties: Query, Insert, Update, Delete, BulkInsert, BatchInsert, CompoundQuery
// - public static IReadOnlyList<IDataCommandTranslator> All()
// - public static IDataCommandTranslator ByName(string name)
// - public static IDataCommandTranslator ById(int id)
}Usage in composite translator:
var translator = MsSqlDataCommandTranslators.Query;
return translator.Translate(command, container, cancellationToken); // Overload resolution!- Eliminates reflection overhead - no GetType(), GetProperty(), GetValue()
- Compile-time dispatch - overload resolution at compile time
- Zero boxing - strongly-typed interfaces
- Hot path optimization - command execution is performance-critical
- Compile-time verification - can't access properties that don't exist
- No null reference exceptions - interfaces are explicit contracts
- IntelliSense support - IDE knows available properties
- Self-documenting - interfaces show command capabilities
- Clear separation - error path vs success path
- Easy debugging - no reflection magic to trace
- Testable - can mock interfaces
Use MessageLogging (not string literals) for errors.
From MsSqlTranslatorLog.cs:10-24:
public static partial class MsSqlTranslatorLog
{
/// <summary>
/// Logs when a translator receives an invalid command type.
/// </summary>
[MessageLogging(
EventId = 2001,
Level = LogLevel.Error,
Message = "Translator '{translatorName}' expected {expectedType} but received {actualType}")]
public static partial IGenericMessage InvalidCommandType(
ILogger logger,
string translatorName,
string expectedType,
string actualType);
}Call with NullLogger (no DI needed for error cases):
MsSqlTranslatorLog.InvalidCommandType(
NullLogger<MsSqlQueryTranslator>.Instance,
"MsSqlQueryTranslator",
"IQueryCommand",
command.GetType().Name)✅ Use dispatcher pattern when:
- You have multiple implementations of a common interface (translators, handlers, services)
- You need to route based on a type/category property
- Performance matters (hot path, high throughput)
- You want compile-time type safety
❌ Don't use when:
- You only have 2-3 implementations (simple if/switch is fine)
- Performance isn't critical
- The overhead of interfaces outweighs benefits
| Pattern | Dispatch Speed | Type Safety | Complexity |
|---|---|---|---|
| Reflection | Slow (runtime) | ❌ None | Low |
| Switch on GetType() | Medium | ❌ None | Low |
| Switch on property | Fast (string compare) | Medium | |
| Method overload | Fastest (compile-time) | ✅ Full | Medium |
| Visitor pattern | Fast | ✅ Full | High |
Method overload dispatcher provides the best balance of performance, type safety, and simplicity.
Command Interfaces:
Command Implementations:
Translators:
Dispatcher (TypeCollection):
-
MsSqlDataCommandTranslators.cs- source-generated
MessageLogging:
- TypeCollections Overview - Compile-time type discovery
- MessageLogging Overview - Structured logging with IGenericMessage