Single-Dispatch Request/Response
A command needs to reach its single handler with a typed response — the operation either succeeded or failed, and the call site needs to know which. The Event Bus is the wrong shape (broadcast, no return). The Mediator from @frenchexdev/ddd-mediator is the right one: typed Request<TResponse>, single registered handler, awaited response.
What Mediator Reifies
MediatR (the .NET library) named this shape clearly enough that the pattern is recognisable across languages. Request<TResponse> is a marker interface — a class implementing it declares the response type it expects. RequestHandler<TRequest, TResponse> is the receiver — one method, handle(request), returning Promise<TResponse>. Mediator.register(ctor, handler) wires the two. Mediator.send(request) dispatches.
The single-dispatch contract is what distinguishes the Mediator from the Event Bus. The Mediator throws — HandlerAlreadyRegisteredError on duplicate, NoHandlerRegisteredError on missing — because both situations indicate a configuration mistake the application cannot recover from. The Event Bus, by contrast, drops events that have no subscriber and surfaces a warning, because broadcast semantics legitimately allow zero subscribers.
The Runtime: ddd-mediator
mediator.ts declares the surface. The Request<TResponse> interface carries a __request: true marker and a phantom TResponse parameter that the type system uses to infer the handler's return type. RequestHandler<TRequest, TResponse> is the typed receiver.
The class itself holds a Map<Function, RequestHandler<unknown, unknown>> keyed by request constructor:
export class Mediator {
private readonly handlers = new Map<Function, RequestHandler<Request<unknown>, unknown>>();
register<TRequest extends Request<TResponse>, TResponse>(
requestCtor: RequestCtor<TRequest>,
handler: RequestHandler<TRequest, TResponse>,
): void {
if (this.handlers.has(requestCtor)) {
throw new HandlerAlreadyRegisteredError(requestCtor.name);
}
this.handlers.set(requestCtor, handler as unknown as RequestHandler<Request<unknown>, unknown>);
}
async send<TResponse>(request: Request<TResponse>): Promise<TResponse> {
const ctor = (request as object).constructor as Function;
const handler = this.handlers.get(ctor);
if (!handler) throw new NoHandlerRegisteredError(ctor.name);
return await handler.handle(request as Request<unknown>) as TResponse;
}
has(requestCtor: RequestCtor<Request<unknown>>): boolean {
return this.handlers.has(requestCtor);
}
}export class Mediator {
private readonly handlers = new Map<Function, RequestHandler<Request<unknown>, unknown>>();
register<TRequest extends Request<TResponse>, TResponse>(
requestCtor: RequestCtor<TRequest>,
handler: RequestHandler<TRequest, TResponse>,
): void {
if (this.handlers.has(requestCtor)) {
throw new HandlerAlreadyRegisteredError(requestCtor.name);
}
this.handlers.set(requestCtor, handler as unknown as RequestHandler<Request<unknown>, unknown>);
}
async send<TResponse>(request: Request<TResponse>): Promise<TResponse> {
const ctor = (request as object).constructor as Function;
const handler = this.handlers.get(ctor);
if (!handler) throw new NoHandlerRegisteredError(ctor.name);
return await handler.handle(request as Request<unknown>) as TResponse;
}
has(requestCtor: RequestCtor<Request<unknown>>): boolean {
return this.handlers.has(requestCtor);
}
}The implementation is forty-odd lines. The complexity is in the typing — the Request<TResponse> phantom parameter is what makes send(req) return Promise<TResponse> correctly inferred from the request's declaration. At runtime the casts are necessary because the registry is heterogeneous; the type system makes the casts safe.
In our invented setup:
import { Mediator, type Request, type RequestHandler } from '@frenchexdev/ddd-mediator';
export class GetCustomerByIdQuery implements Request<Customer | null> {
readonly __request = true as const;
constructor(public readonly id: CustomerId) {}
}
class GetCustomerByIdHandler implements RequestHandler<GetCustomerByIdQuery, Customer | null> {
constructor(private readonly customers: CustomerRepositoryPort) {}
async handle(q: GetCustomerByIdQuery): Promise<Customer | null> {
return this.customers.findById(q.id);
}
}
const mediator = new Mediator();
mediator.register(GetCustomerByIdQuery, new GetCustomerByIdHandler(/* deps */));
const customer = await mediator.send(new GetCustomerByIdQuery(id('Customer', 'cu_abc')));
// ^^^^^^^^ inferred as Customer | null — the type flows from the Request declarationimport { Mediator, type Request, type RequestHandler } from '@frenchexdev/ddd-mediator';
export class GetCustomerByIdQuery implements Request<Customer | null> {
readonly __request = true as const;
constructor(public readonly id: CustomerId) {}
}
class GetCustomerByIdHandler implements RequestHandler<GetCustomerByIdQuery, Customer | null> {
constructor(private readonly customers: CustomerRepositoryPort) {}
async handle(q: GetCustomerByIdQuery): Promise<Customer | null> {
return this.customers.findById(q.id);
}
}
const mediator = new Mediator();
mediator.register(GetCustomerByIdQuery, new GetCustomerByIdHandler(/* deps */));
const customer = await mediator.send(new GetCustomerByIdQuery(id('Customer', 'cu_abc')));
// ^^^^^^^^ inferred as Customer | null — the type flows from the Request declarationThe call site does not name the handler; the mediator's registry does the lookup. Refactor the handler — change its name, move it to another file, swap its implementation for a fake — and the call site is unchanged.
The Analyzer: ddd-mediator-analyzer
The analyzer for this pattern is hand-written legacy, like Domain Event, Value Object, and Entity. There is no spec.ts calling defineAnalyzerSpec; instead, codes.ts exports two diagnostic factories in the DDD0NNN namespace. The two codes — DDD0330 and DDD0331 — are the static counterpart of the runtime's two error classes.
DDD0330_MEDIATOR_DUPLICATE_HANDLER catches at AST scan what HandlerAlreadyRegisteredError catches at startup — two handlers declared for the same request class:
export const DDD0330_MEDIATOR_DUPLICATE_HANDLER = 'DDD0330';
export function mediatorDuplicateHandler(requestClass: string, handlerA: string, handlerB: string, file: string): Diagnostic {
return {
code: DDD0330_MEDIATOR_DUPLICATE_HANDLER,
severity: 'error',
message: `Request "${requestClass}" has two handlers declared: "${handlerA}" and "${handlerB}". Mediator dispatch is single-handler — remove one.`,
file,
};
}export const DDD0330_MEDIATOR_DUPLICATE_HANDLER = 'DDD0330';
export function mediatorDuplicateHandler(requestClass: string, handlerA: string, handlerB: string, file: string): Diagnostic {
return {
code: DDD0330_MEDIATOR_DUPLICATE_HANDLER,
severity: 'error',
message: `Request "${requestClass}" has two handlers declared: "${handlerA}" and "${handlerB}". Mediator dispatch is single-handler — remove one.`,
file,
};
}DDD0331_MEDIATOR_REQUEST_NO_HANDLER is the static counterpart of NoHandlerRegisteredError — a Request class with no handler anywhere in the workspace. Error severity; mediator.send() at runtime would throw.
The hand-written shape is on the migration list — PROP-MEDIATOR-001 (to file) will rewrite the analyzer on top of defineAnalyzerSpec with DDD-MEDIATOR-001/002. The two codes are part of the contract that must survive the migration, which is why they exist as named constants rather than only as inline strings.
The Codegen: ddd-mediator-codegen
The codegen, like the analyzer, predates the spec-first templates pattern. generator.ts exports a single function generateMediatorRegistry(input) that consumes a GenerateMediatorRegistryInput (handlers: { requestClass, handlerClass }[]) and returns the source of a banner-stamped wireMediator() function:
// AUTO-GENERATED by ddd-mediator-codegen@0.0.1 — do not edit.
/* eslint-disable */
// Mediator wireup: registers every (Request, Handler) pair at composition root.
export function wireMediator(mediator: { register(req: unknown, handler: unknown): void }): void {
mediator.register(GetCustomerByIdQuery, new GetCustomerByIdHandler());
mediator.register(StartSubscriptionCommand, new StartSubscriptionHandler());
// ...
}// AUTO-GENERATED by ddd-mediator-codegen@0.0.1 — do not edit.
/* eslint-disable */
// Mediator wireup: registers every (Request, Handler) pair at composition root.
export function wireMediator(mediator: { register(req: unknown, handler: unknown): void }): void {
mediator.register(GetCustomerByIdQuery, new GetCustomerByIdHandler());
mediator.register(StartSubscriptionCommand, new StartSubscriptionHandler());
// ...
}The generated function is what the application calls at composition root. The banner is emitted via withDddBanner from ddd-core-codegen — but no defineCodegenSpec ratifies the formal invariants (idempotent-for-same-input, additive-only) yet. PROP-MEDIATOR-002 (to file) will lift the codegen to spec-first with those invariants declared.
Cross-Links
- Dispatches
@Commandand@Querythrough@CommandHandlerand@QueryHandler. - Wrapped by
@PipelineBehavior— cross-cutting concerns (validation, logging, idempotency) sit betweensendand the handler. - Distinct from
@EventBus— single dispatch with response vs. broadcast without.
Back to the series index.