payable

Domain Events

Domain events live in src/domain/events/. Each event is an immutable record of something that happened in the domain. Each extends DomainEvent and carries a canonical NormalizedEventName, a typed payload, and trace metadata.

The event classes are exported public API (typed contracts consumers can build, dispatch, and subscribe to via the EventBus; default InMemoryEventBus). Two emission paths exist, and they are distinct - read this before assuming an event fires:

  • In-process EventBus (best-effort). The engine itself currently instantiates and emits exactly one of these classes: WebhookProcessedEvent, emitted by the process-webhook pipeline after its transaction commits, fire-and-forget (.emit(...).catch(() => {})). It is not transactional and is not retried. The other 13 classes are provided as typed contracts but are not emitted internally yet - emit them from your own listeners/actions if you need them.
  • Transactional outbox (at-least-once). Durable, replayable publication does not go through these classes. The process-webhook pipeline writes an OutboxEvent row in the same transaction as the webhook state change, keyed by an eventType string of the form ${normalizedType}.v1 (e.g. payment.succeeded.v1) with { providerEventId, data } as payload and a dedupeKey. The outbox relay then delivers it at least once: a crash between delivery and marking the row published redelivers the same event, so consumers must deduplicate on the stable outbox event id (delivered as the envelope id) or on providerEventId. See Reliability. The durable event stream is keyed by normalized-type strings, not by the DomainEvent subclasses below.

Catalog outbox events

When storage is configured, a changed catalog entity and its audit and outbox records commit in one local transaction. Mutation actions use the imperative form. Audit actions name the confirmed state in the past tense, and each outbox event type adds the .v1 schema suffix.

Mutation actionAudit actionOutbox event type
product.createproduct.createdproduct.created.v1
product.updateproduct.updatedproduct.updated.v1
product.activateproduct.activatedproduct.activated.v1
product.archiveproduct.archivedproduct.archived.v1
price.createprice.createdprice.created.v1
price.activateprice.activatedprice.activated.v1
price.archiveprice.archivedprice.archived.v1

Every catalog outbox record has eventVersion: 1, the operation correlationId, and a normalized payload with these fields:

  • action: the portable mutation name, such as product.update.
  • resourceType: product or price.
  • resourceId: the local entity ID.
  • provider: the registered provider name.
  • providerResourceId: the provider’s product or price ID.
  • tenantId: the current tenant or null.
  • state: the normalized durable catalog snapshot.

These records use the transactional outbox, not the in-process EventBus. Consumers must deduplicate catalog delivery using the stable outbox envelope id. providerResourceId identifies the catalog resource and is not a delivery dedupe key.

DomainEvent base

src/domain/events/domain-event.ts. The abstract base every event extends.

export abstract class DomainEvent<P = unknown> {
  readonly eventId: string;
  readonly payload: Readonly<P>;

  constructor(
    readonly name: NormalizedEventName,
    payload: P,
    readonly correlationId: string,
    readonly occurredAt: Date,
    readonly version: number = 1,
  ) {
    this.eventId = globalThis.crypto.randomUUID();
    this.payload = Object.freeze(payload) as Readonly<P>;
  }
}
  • eventId - a fresh UUID per instance.
  • name - a NormalizedEventName (the canonical cross-provider event type, e.g. payment.succeeded).
  • payload - the typed payload, frozen on construction.
  • correlationId, occurredAt, version - trace id, instant, and schema version (defaults to 1).

Each concrete event takes (payload, meta: DomainEventMeta), where DomainEventMeta = { correlationId, occurredAt }, and passes its fixed NormalizedEventName to super.

export interface DomainEventMeta {
  correlationId: string;
  occurredAt: Date;
}

Event catalog

The 14 concrete event classes and their payload types. The “Name” column is the NormalizedEventName the event carries; note the Invoice* and Subscription* class names do not always match their wire names one-to-one. The “Semantics” column is the domain fact the event type represents - not a guarantee the engine emits it (only WebhookProcessedEvent is emitted internally; see above).

Event classNamePayload typeSemantics
CustomerCreatedEventcustomer.createdCustomerCreatedPayloadA customer was provisioned.
CheckoutCreatedEventcheckout.createdCheckoutCreatedPayloadA checkout session was opened.
SubscriptionCreatedEventsubscription.createdSubscriptionCreatedPayloadA subscription was created.
SubscriptionUpdatedEventsubscription.updatedSubscriptionUpdatedPayloadA subscription was swapped/updated.
SubscriptionCancelledEventsubscription.cancelledSubscriptionCancelledPayloadA subscription was cancelled.
SubscriptionResumedEventsubscription.resumedSubscriptionResumedPayloadA cancelled subscription was resumed.
PaymentSucceededEventpayment.succeededPaymentSucceededPayloadA payment settled successfully.
PaymentFailedEventpayment.failedPaymentFailedPayloadA payment failed.
RefundCreatedEventrefund.createdRefundCreatedPayloadA refund was issued against a payment.
InvoiceCreatedEventinvoice.createdInvoiceCreatedPayloadAn invoice was created.
InvoicePaidEventinvoice.paidInvoicePaidPayloadAn invoice was paid.
InvoiceFailedEventinvoice.payment_failedInvoiceFailedPayloadAn invoice payment failed.
WebhookReceivedEventwebhook.receivedWebhookReceivedPayloadA provider webhook was received and persisted.
WebhookProcessedEventwebhook.processedWebhookProcessedPayloadA webhook finished processing. Emitted by the process-webhook pipeline (best-effort, post-commit).

The NormalizedEventName union (in domain-event.ts) is the closed set of canonical names. It is wider than the 14 classes above - it also includes provider-mapped names such as customer.updated, checkout.completed, refund.succeeded, and refund.failed, which appear as outbox eventType values (suffixed .v1) even though no dedicated event class exists for them.

Representative payloads

export interface PaymentSucceededPayload {
  paymentId: string;
  customerId: string | null;
  amount: Money;
}

export interface SubscriptionCreatedPayload {
  subscriptionId: string;
  customerId: string;
  name: string;
  status: SubscriptionStatus;
}

export interface WebhookReceivedPayload {
  webhookEventId: string;
  provider: string;
  providerEventId: string;
  type: string;
}

Monetary fields on payloads carry Money (for example amount / total), not raw integers. See each *.event.ts file for the full payload definitions.