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 (ouvre dans une nouvelle fenêtre) 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