Polymorphic Messages
Overview
Section titled “Overview”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.
When to use
Section titled “When to use”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.
When not to use
Section titled “When not to use”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.
Example
Section titled “Example”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.
Behind the scenes
Section titled “Behind the scenes”- The parents-of mapping is captured at registration time on the registry:
registry.register<OrderPlaced>('OrderPlaced', { parents: ['DomainEvent'] })records thatOrderPlacedis-aDomainEvent. - A consumer binds its queue only to the exchanges for the types it actually consumes (handlers, sagas, aggregators) — register and handle
DomainEventand the queue binds to theDomainEventexchange. 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
rabbitMQWithRegistrypublishesOrderPlaced, it resolves the ancestor closure viaregistry.parentsOf—OrderPlacedplusDomainEvent(transitively) — and publishes a copy of the message to each of those type exchanges: theOrderPlacedexchange and theDomainEventexchange. - A
DomainEventsubscriber receives the copy published to theDomainEventexchange; anOrderPlacedsubscriber receives the copy published to theOrderPlacedexchange. 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’sparentsOflookup into the transport so the producer can resolve and publish to ancestor exchanges. The plaincreateRabbitMQTransportfactory has no view of inheritance, so a publishedOrderPlacedreaches only theOrderPlacedexchange.
See also
Section titled “See also”- Runnable example:
examples/polymorphic/ IMessageTypeRegistry- Pub/Sub