DSoftStudio Mediator

← Back to Documentation

ADR-0004: Runtime-Typed Send(object) Dispatch

Status

Released in v1.1.0

Context

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("e@mail.com");
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:

The asymmetry between Publish(object) (supported) and Send(object) (not supported) blocks a common real-world pattern.

Decision

Add a runtime-typed Send(object) overload that follows the exact same architecture as Publish(object):

  1. RequestObjectDispatch — new static class with a FrozenDictionary<Type, DispatchDelegate> dispatch table, mirroring NotificationObjectDispatch.

  2. 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?.

  3. SenderObjectExtensions.Send(this ISender, object, CancellationToken) → ValueTask<object?> — extension method (not an interface method — see Design constraint: overload resolution).

  4. PrecompilePipelines() — calls RequestObjectDispatch.Freeze() after all registrations.

Design constraint: overload resolution

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:

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.

Additional design constraints

Consequences

Positive

Negative

Neutral


Document History

Date Version Changes
— Draft Initial ADR with design proposal
2026-03-15 v1.1.0 Released with FrozenDictionary-based dispatch
2026-09-16 v1.4.0 Superseded in part — see the amendment below

Amendment, 2026-09-16 — the dictionary became the fallback

The decision recorded above still holds in what it was protecting: no reflection, no MakeGenericType, AOT-safe. What changed is the primary route.

Send(object) now compiles to a generated switch (request) over the request types the compilation can see, each case calling into that pair’s dispatch directly. The FrozenDictionary described above is still there and still registered the same way, but it is reached only through the switch’s default: — a request type from an assembly compiled without the generator. It is now the fallback, not the mechanism.

One detail is load-bearing and was not obvious. The case bodies live in their own methods rather than inside the switch. Inlined, the switch grew about 1.2 KB of machine code per request type, and past nine types it left the JIT’s inlining budget: Send(object) went from 5.5 ns to 9.5 ns on a single added type, and by forty types the method had grown to roughly the size of an L1 instruction cache, costing 15 ns. Outlined, it stays flat from nine types to eighty.