Skip to content

Event translators ​

An event translator turns the domain events of an aggregate into integration events: plain JSON in the published language of the bounded context. It is the one place where the internal model meets the contract other contexts read.

ts
export class OrderEventsTranslator extends EventTranslator<OrderPlaced, OrderPlacedRepresentation> {
	protected readonly source = "ordering";

	translate(event: OrderPlaced, context: IntegrationEventContext): OrderPlacedRepresentation {
		return this.wrap(event, context, { payload: { orderId: event.aggregateId.value, total: event.payload.total }, type: "OrderPlaced", version: 1 });
	}
}

When to use ​

Write one translator per aggregate whose events leave the bounded context. The domain keeps its own types, such as identifiers and value objects; the translator decides what is published and in which form, so a refactoring of the domain never reaches other contexts by accident.

Usage ​

Translate the events of an aggregate ​

Event is the union of the domain events of the aggregate, Output the union of the integration events declared in the published language. Tell the events apart with instanceof, and wrap each one: wrap copies id and occurredAt from the domain event, adds source and the context, and takes the type, version and payload you give.

src/ordering/application/translators/order-events.translator.ts
ts
import { EventTranslator, type IntegrationEventContext } from "@alveolus/core";

import { OrderPlaced } from "../../domain/events/order-placed.event";
import type { OrderEvent } from "../../domain/events/order.event";
import type { OrderingEvent } from "../../published-language/ordering-event.representation";

export class OrderEventsTranslator extends EventTranslator<OrderEvent, OrderingEvent> {
	protected readonly source = "ordering";

	translate(event: OrderEvent, context: IntegrationEventContext): OrderingEvent {
		if (event instanceof OrderPlaced) {
			return this.wrap(event, context, {
				payload: { currency: event.payload.total.currency, orderId: event.aggregateId.value, total: event.payload.total.amount },
				type: "OrderPlaced",
				version: 1,
			});
		}
		return this.wrap(event, context, { payload: { orderId: event.aggregateId.value }, type: "OrderCancelled", version: 1 });
	}
}

OrderingEvent is the union of the representations, such as OrderPlacedRepresentation | OrderCancelledRepresentation: TypeScript rejects a type or a payload the contract does not declare.

Call it from the command handler ​

Inject the translator and map the events pulled from the aggregate, inside the unit of work, after saving.

src/ordering/application/commands/place-order.command.ts
ts
await this.orders.save(order);
await this.outbox.add(order.pullDomainEvents().map((event) => this.translator.translate(event, { correlationId: orderId })));

Keep domain objects out of the payload ​

The payload is JSON: identifiers and value objects are written as strings and numbers.

❌ Avoid
ts
payload: { order: event.aggregateId, total: event.payload.total }
✅ Prefer
ts
payload: { orderId: event.aggregateId.value, total: event.payload.total.amount }

The payload type is constrained to JsonValue: an Identifier or a Money does not compile.

Chain operations ​

When the change was caused by another message, pass its id as causationId and keep its correlationId: the whole chain can be followed across contexts.

ts
this.translator.translate(event, { causationId: received.id, correlationId: received.correlationId });

Reference ​

ts
abstract class EventTranslator<Event extends AnyDomainEvent, Output extends AnyIntegrationEvent = AnyIntegrationEvent> {
	protected abstract readonly source: string;
	abstract translate(event: Event, context: IntegrationEventContext): Output;
	protected wrap<Type extends string, Payload extends JsonValue>(
		event: Event,
		context: IntegrationEventContext,
		contract: { readonly type: Type; readonly version: number; readonly payload: Payload },
	): IntegrationEvent<Type, Payload>;
}
Type parameterDescription
EventThe domain events it translates.
OutputThe integration events it produces. Defaults to any integration event.
MemberTypeDescription
sourcestring, protected abstractThe bounded context the events come from.
translate(event, context)OutputTurns one domain event into its integration event.
wrap(event, context, contract)IntegrationEvent<Type, Payload>, protectedBuilds the integration event from { type, version, payload }, with id, occurredAt, source, correlationId and causationId filled in.

Caveats

  • occurredAt becomes an ISO 8601 string, and causationId is only set when the context has one.
  • The translator lives in the application layer: it reads the domain and the published language, the domain never knows it.

Import from @alveolus/core or @alveolus/core/event-translators.

See also ​

Released under the MIT License.