Released in v1.1.0
In message bus / command queue architectures, the consumer deserializes a command from
the wire (JSON, Protobuf, etc.) and only has the runtime Type + an object reference.
The consumer needs to dispatch the command through the mediator pipeline without knowing
TResponse at compile time.
// Producer
var command = new CreateUserCommand("[email protected]");
queue.Push((JsonSerializer.Serialize(command), command.GetType()));
// Consumer — only has object + Type at runtime
(string raw, Type type) = queue.Pop();
var command = JsonSerializer.Deserialize(raw, type);
await mediator.Send(command!); // <- needs runtime-typed dispatch
Current state:
Send<TRequest, TResponse>(TRequest) — requires both type params at compile timeSend(Ping request) — generated typed extensions, compile-time onlyPublish(object) — already supports runtime-typed dispatch via NotificationObjectDispatch
(compile-time generated FrozenDictionary<Type, DispatchDelegate>)The asymmetry between Publish(object) (supported) and Send(object) (not supported)
blocks a common real-world pattern.
Add a runtime-typed Send(object) overload that follows the exact same architecture
as Publish(object):
RequestObjectDispatch — new static class with a FrozenDictionary<Type, DispatchDelegate>
dispatch table, mirroring NotificationObjectDispatch.
Source generator — MediatorPipelineGenerator emits RequestObjectDispatch.Register<TReq, TRes>()
for every discovered request type, with a delegate that casts object → TRequest,
resolves the handler/pipeline, calls Handle(), and boxes the response to object?.
SenderObjectExtensions.Send(this ISender, object, CancellationToken) → ValueTask<object?>
— extension method (not an interface method — see Design constraint: overload resolution).
PrecompilePipelines() — calls RequestObjectDispatch.Freeze() after all registrations.
Send(object) is implemented as an extension method rather than an interface method.
This is a deliberate design choice driven by C# overload resolution rules:
ISender has Send<TRequest, TResponse>(TRequest) with two generic type parameters.
Unlike MediatR’s Send<TResponse>(IRequest<TResponse>) (one param, inferable), TResponse
cannot be inferred from the argument alone.Send(this ISender, Ping)) to
solve the inference problem.Send(object) were on the interface, mediator.Send(new Ping()) would resolve to
Send(object) instead of the generated typed extension — breaking all existing call sites.Send(this ISender, Ping) (more specific) wins over
Send(this ISender, object) (less specific) by normal overload resolution.This differs from Publish(object) which is on IPublisher — there, Publish<T>(T)
has one type parameter that can be inferred from the argument, so the generic overload
always wins over Publish(object) without ambiguity.
Zero impact on the existing hot path. The Send<TRequest, TResponse>() path (static
generic dispatch, interceptors, HandlerCache) is completely untouched. The new overload
is a separate code path with its own dispatch table.
AOT-safe. No MakeGenericType, no Expression.Compile, no reflection at runtime.
The source generator emits concrete delegates per request type.
Boxing is acceptable. The TResponse is boxed to object? in the dispatch delegate.
This only affects the Send(object) path. In queue/bus scenarios, the deserialization
cost (~μs) dwarfs the boxing cost (~1 ns, 16-24 B for value types).
dynamic or reflectionPublish(object) for notifications, Send(object) for requestsNotificationObjectDispatch — no new patterns to maintainSend<TRequest, TResponse>() performanceFrozenDictionary in static memory (~bytes per registered request type)TResponse boxing on the Send(object) path for value type responsesMediatorRegistry.g.csSend(object) pay zero cost — the dispatch table is populated
at startup regardless (same as NotificationObjectDispatch), but never queried| Date | Version | Changes |
|---|---|---|
| — | Draft | Initial ADR with design proposal |
| 2026-03-15 | v1.1.0 | Released with FrozenDictionary-based dispatch |