Skip to content

Polymorphic Messages

Polymorphic messages let you model an inheritance hierarchy in your event schema and have the bus respect it on the wire. Declare that OrderPlaced is a DomainEvent, register a handler against DomainEvent, and that handler will fire for every subtype — OrderPlaced, OrderCancelled, RefundIssued — without any per-type wiring. One publish of an OrderPlaced fans out to every queue bound to OrderPlaced and every queue bound to a declared parent.

Reach for polymorphism when you have an open-ended family of events and a cross-cutting consumer that doesn’t care which specific variant arrived. Audit logs, outbox forwarders, search indexers, and metrics collectors all benefit: they want every DomainEvent that passes through the system, and they should keep working when you add a new subtype tomorrow.

It is also the right shape when a downstream context truly only needs the base contract. A reporting service that records { correlationId, occurredAt } does not need to know about discriminator-specific fields — by handling DomainEvent it stays decoupled from the producer’s catalogue.

Avoid polymorphism when each subtype carries materially different semantics that demand bespoke handling. If your DomainEvent handler has to switch on messageType and branch into wildly different logic, you have lost the win — register concrete handlers via Pub/Sub instead.

import { type Message, createBus, createMessageTypeRegistry } from '@serviceconnect/core';
import { rabbitMQWithRegistry } from '@serviceconnect/rabbitmq';
interface DomainEvent extends Message {
occurredAt: string;
}
interface OrderPlaced extends DomainEvent {
orderId: string;
}
const registry = createMessageTypeRegistry();
registry.register<DomainEvent>('DomainEvent');
registry.register<OrderPlaced>('OrderPlaced', { parents: ['DomainEvent'] });
const bus = createBus({
transport: rabbitMQWithRegistry({ url: 'amqp://localhost' }, registry),
queue: { name: 'audit' },
})
.registerMessage<DomainEvent>('DomainEvent')
.handle<DomainEvent>('DomainEvent', async (e, ctx) => {
await audit.log(e.occurredAt, ctx.messageType);
});

The audit queue handles DomainEvent. When a producer somewhere else in the topology publishes OrderPlaced, the audit consumer receives it — because OrderPlaced was registered with DomainEvent as a parent. The handler still gets the full payload; it just sees it through the DomainEvent lens. ctx.messageType (equivalently ctx.headers.messageType) carries the concrete type string, so downstream code can disambiguate when needed.

  • The parents-of mapping is captured at registration time on the registry: registry.register<OrderPlaced>('OrderPlaced', { parents: ['DomainEvent'] }) records that OrderPlaced is-a DomainEvent.
  • A consumer binds its queue only to the exchanges for the types it actually consumes (handlers, sagas, aggregators) — register and handle DomainEvent and the queue binds to the DomainEvent exchange. Consumers do not add parent-exchange bindings; binding a queue to both a type and its ancestor would deliver a multi-published message twice.
  • The fanout happens on the producing side, by multi-publish. When a producer built with rabbitMQWithRegistry publishes OrderPlaced, it resolves the ancestor closure via registry.parentsOfOrderPlaced plus DomainEvent (transitively) — and publishes a copy of the message to each of those type exchanges: the OrderPlaced exchange and the DomainEvent exchange.
  • A DomainEvent subscriber receives the copy published to the DomainEvent exchange; an OrderPlaced subscriber receives the copy published to the OrderPlaced exchange. Each exchange fans its own copy out to the queues bound to it. There are no exchange-to-exchange bindings — the producer addresses every ancestor exchange directly.
  • This is why the producer uses rabbitMQWithRegistry: it threads the registry’s parentsOf lookup into the transport so the producer can resolve and publish to ancestor exchanges. The plain createRabbitMQTransport factory has no view of inheritance, so a published OrderPlaced reaches only the OrderPlaced exchange.