Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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

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 declaration

The 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,
  };
}

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());
  // ...
}

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.


Back to the series index.

⬇ Download