Internal domain events: emitter, listener, and delivery
When a business rule completes in payment orchestration, for example when a payment record is created, the system usually does more than write to the database. Side effects kick in: sending notifications, producing audit logs, triggering integrations added later, or feeding analytics pipelines. Embedding these side effects directly in the use case body may look fast in the short term, but over time it hurts readability, makes testing harder, and lets a single change wave break the entire flow.
In this post we cover our answer to that problem in the Hsrcpay backend: an in-process event bus built on domain events. Events are published at meaningful domain moments; listeners register for the EventType they care about; and the delivery layer maps those subscriptions and invokes handlers. The design essence is simple: the use case announces "what happened" once; the question of "who does what as a result" lives in separate modules, loosely coupled.
Today's boundary (a deliberate choice): This path is not a source of truth (SOT) for now. An event is a lightweight notification that says "something happened, the right parties should act"; core state such as payment or account already lives in the database and domain rules. Delay, duplication, or theoretical loss of an in-process notification on the bus must not break the core business rule on its own; listeners are designed accordingly: they own in-handler error management, idempotent side effects, and, when needed, creating a durable "job record" (for example a webhook delivery row). Real delivery guarantees will move to a broker and queue layer later (roadmap below).
Recent iterations on the payment flow, vault integration, and bootstrap layer continue to evolve this structure under the same roof. In particular, resolving sandbox and prod infrastructure separately (resolveInfra / resolveRepositories) requires the event bus to run in isolation across two separate instances; unit tests validate that isolation through a real payment creation scenario.
Below, each major section first explains the concept and design rationale at length, then shows the relevant technical code examples. The snippets shown are simplified or merged excerpts from repo sources for readability.
Architecture map: dual bus and bootstrap registration
In financial software, mixing test and live data leads to hard-to-recover errors. That is why infrastructure bootstrap creates separate Prisma clients, cache, vault, and similar services for prod and sandbox, and also keeps two separate event bus instances. In production mode, ProdEventBus is used for both environments; in development and unit tests, DummyEventBus keeps behavior controlled. Both implement the same IDomainEventBus contract, so application code is not tightly bound to a concrete class.
When the application starts, registered domain event listeners are intentionally added to both prod and sandbox buses. The same listener class is written once; the triggered event flows through the correct database and correct bus according to the request's sandbox flag. This split supports the principle of "listener code once, behavior in two isolated worlds."
// Source: apps/backend/src/infrastructure/bootstrap/infrastructure.ts
// createEventBuses - ProdEventBus or DummyEventBus pair by mode
export function createEventBuses(
mode: "production" | "development" | "unit_test",
): EnvGroup<IDomainEventBus> {
if (mode === "production") {
return {
prod: new ProdEventBus(),
sandbox: new ProdEventBus(),
};
} else {
return {
prod: new DummyEventBus(),
sandbox: new DummyEventBus(),
};
}
}
// Assigned to prod / sandbox infra groups inside bootstrapInfrastructure:
// eventBus: eventBus.prod and eventBus: eventBus.sandbox// Source: apps/backend/src/infrastructure/bootstrap/event-listeners.ts (summary)
export const bootstrapEventListeners = (
ib: Pick<
TInfrastructureBootstrap,
| "repositories"
| "infra"
| "environment"
| "application"
| "paymentOrchestrator"
>,
) => {
const listeners = [...createPaymentModuleListeners(ib)];
for (const listener of listeners) {
ib.infra.prod.eventBus.registerDomainEventListener(listener);
ib.infra.sandbox.eventBus.registerDomainEventListener(listener);
}
};Event object: DomainEventAbstractBase and concrete events
A domain event is a statement of fact in the sense of "it happened in the past and cannot be undone." Technically, subclasses of DomainEventAbstractBase are each tagged with an EventType, expose identity, creation time, and sandbox flag from shared storage (eventStorage). Concrete events, such as payment created, store related entities (payment, account) in that storage and expose them through type-safe getters.
The design also defines broadcastTo and broadcastMap fields for future use; they are not used in the current publish loop yet, but remain extension points for multi-target broadcast or mapping layers added later. We revisit this detail in the roadmap section.
// Source: apps/backend/src/domain/common/event-abstract-base.ts (summary)
import type { AccountEntity } from "../resources/account";
export abstract class DomainEventAbstractBase {
public abstract type: EventType;
public eventStorage = new DomainEventStorage();
public broadcastTo: TEventBroadcastFieldOptions;
public broadcastMap: TEventBroadcastMapperFunction;
constructor(sandboxMode: boolean = false) {
this.eventStorage.set("id", generateEventId(sandboxMode));
this.eventStorage.set("sandboxMode", sandboxMode);
this.eventStorage.set("createdAt", new Date());
}
public get id(): string {
return this.eventStorage.get<string>("id")!;
}
public get accountId(): string | undefined {
return this.eventStorage.get<AccountEntity>("account")?.id;
}
public get createdAt(): Date {
return this.eventStorage.get<Date>("createdAt")!;
}
public get sandboxMode(): boolean {
return this.eventStorage.get<boolean>("sandboxMode")!;
}
}// Source: apps/backend/src/domain/resources/payment/events/created.event.ts
export class PaymentCreatedDomainEvent extends DomainEventAbstractBase {
public type = EventType.PAYMENT_CREATED;
constructor(
payment: PaymentEntity,
account: AccountEntity,
options: TEventCreteOptions,
) {
super(options.sandboxMode);
this.eventStorage.set("payment", payment);
this.eventStorage.set("account", account);
}
public get payment(): PaymentEntity {
return this.eventStorage.get<PaymentEntity>("payment")!;
}
public get account(): AccountEntity {
return this.eventStorage.get<AccountEntity>("account")!;
}
}Emitter: publish from inside the use case
The emitter role here is not a classic EventEmitter; it is the IDomainEventBus.publish call from infrastructure. The critical point is that once the use case knows which environment it runs in, it selects the correct eventBus instance via resolveInfra(ib.infra, sandboxMode). A sandbox request writes to the sandbox database, then leaves the event on the sandbox bus; a prod request goes to the prod bus. This one-line resolution reduces the architectural risk of test events accidentally reaching live listeners.
After creating the payment row, the use case body publishes a single domain event; work such as email, external webhooks, or reporting is not queued right below that call. It moves to listeners. In a webhook scenario, a listener typically does not treat a direct HTTP call as the "final step"; it opens a webhook delivery record, and the actual HTTP call runs via a worker with a queue or scheduler. Today's domain event is only the first alert in that chain.
In production mode, ProdEventBus.publish returns without waiting for matching listeners' handle results (fire-and-forget). Side effects are not considered complete within the same tick; this lowers HTTP request latency but removes the assumption that "publish finished, therefore all side effects finished." Development and unit test environments use DummyEventBus, which keeps the same contract but awaits handle calls sequentially inside publish; spy listeners fill deterministically in tests for that reason.
// Source: apps/backend/src/application/use-cases/payment/create-payment.useCase.ts (summary)
const repos = resolveRepositories(this.ib.repositories, sandboxMode);
const infra = resolveInfra(this.ib.infra, sandboxMode);
const result = await repos.payment.createPayment({
account_id: params.accountId,
amount: params.amount,
currency: params.currency,
status: "CREATED",
// ...
});
await infra.eventBus.publish(
new PaymentCreatedDomainEvent(result, account, { sandboxMode }),
);Listener and subscription: contract, listenTo, and subscription
Listeners are subclasses of DomainEventListenerAbstract; in the constructor they reach repositories and other services through ib from TInfrastructureBootstrap (can be enriched with full DI later). The listenTo field may be a single EventType, an array, or a factory function; during bus registration, resolveListenerEventTypes reduces this to a flat EventType[] list. Today's expectation: inside the handler, try-catch, meaningful logging, retry-safe side effects, and binding "heavy work" to durable records; the bus itself is not a guaranteed job queue.
Registration returns a DomainEventBusSubscription: a unique id and subscribed event types. The id is built from three segments generated via cuid2 (hyphens and a specific prefix stripped from createId output); ProdEventBus and DummyEventBus share the same registration schema. In tests or dynamic scenarios, unregisterDomainEventListener can remove a subscription; this is especially useful in test teardown to prevent leakage and double firing.
// Source: apps/backend/src/infrastructure/infra/interfaces/event-bus.ts (summary)
/** First attempt plus three retries when dispatching to a listener. */
export const MAX_LISTENER_DISPATCH_ATTEMPTS = 4;
export function resolveListenerEventTypes(
listener: DomainEventListenerAbstract,
): EventType[] {
const raw =
typeof listener.listenTo === "function"
? (listener.listenTo() ?? [])
: (listener.listenTo ?? []);
return Array.isArray(raw) ? raw : [raw];
}
export interface IDomainEventBus {
publish(event: DomainEventAbstractBase): Promise<void>;
registerDomainEventListener(
listener: DomainEventListenerAbstract,
): Promise<DomainEventBusSubscription>;
unregisterDomainEventListener(
subscription: DomainEventBusSubscription,
): Promise<void>;
}// Source: apps/backend/src/domain/common/event-listener.ts
export abstract class DomainEventListenerAbstract {
constructor(
protected readonly ib: Pick<
TInfrastructureBootstrap,
"repositories" | "infra" | "environment" | "application"
>,
) {}
public abstract listenTo:
| TEventListenerDefinition
| TEventListenerDefinitionCallable;
public abstract handle(
event: DomainEventAbstractBase,
sandboxMode: boolean,
): Promise<void>;
}// Source: apps/backend/src/application/payment/listeners/test.listener.ts
export class TestPaymentListener extends DomainEventListenerAbstract {
public listenTo = EventType.PAYMENT_CREATED;
public override handle(
event: PaymentCreatedDomainEvent,
sandboxMode: boolean,
) {
return Promise.resolve();
}
}// Source: apps/backend/src/application/payment/listeners/index.ts
export const createPaymentModuleListeners = (ib) => {
return [new TestPaymentListener(ib)];
};Delivery: fire-and-forget in prod, await in development, retry, and logging
Event delivery now splits by concrete class.
ProdEventBus (production mode): At the start of publish, it takes a snapshot of subscriptions (Array.from(this.subscriptions.values())), starts listener.handle for each matching subscription inside a separate async IIFE, and does not await results. Thus publish returns quickly; when multiple listeners match, handlers can run concurrently in practice. The retry loop lives inside that IIFE; on error, the same listener is retried up to MAX_LISTENER_DISPATCH_ATTEMPTS.
DummyEventBus (development / unit_test): The same matching and retry logic runs with direct await inside the publish body; subscriptions are processed sequentially in loop order, and the listener completes before the use case's publish finishes.
Both implementations target limited resilience against same-process transient errors with MAX_LISTENER_DISPATCH_ATTEMPTS (four attempts total: first plus three retries). CHError and other errors are distinguished in log messages; when attempts are exhausted, a "max retries" record is logged with the event type. This does not recover an event lost if the process restarts or the message is never processed; for critical external integrations, the real SOT and retry live on the delivery table + worker + broker path. This split shortens request duration in production while keeping deterministic verification in tests.
// Source: apps/backend/src/infrastructure/infra/prod/event-bus.ts (publish summary)
async publish(event: DomainEventAbstractBase): Promise<void> {
const subscriptions = Array.from(this.subscriptions.values());
for (const subscription of subscriptions) {
if (!subscription.events.some((e) => e === event.type)) continue;
const listener = this.eventListeners.get(subscription.id);
if (!listener) continue;
// Fire and forget: dispatch and don't await or collect the result.
(async () => {
for (let attempt = 1; attempt <= MAX_LISTENER_DISPATCH_ATTEMPTS; attempt++) {
try {
await listener.handle(event, event.sandboxMode);
break;
} catch (error) {
if (error instanceof CHError) {
logger.error("Error handling event:", error);
} else {
logger.error("Unexpected error handling event:", error);
}
if (attempt >= MAX_LISTENER_DISPATCH_ATTEMPTS) {
logger.error("Max retries reached for event:", event.type);
break;
}
}
}
})();
}
// Note: the publish Promise resolves before handlers complete
}The diagram below summarizes the high-level flow for a sandbox request:
Unit tests: spy listener and teardown
Tests are the specification reflected in running code. In bus.test.ts, PaymentCreatedSpyListener creates a payment through bootstrap prepared by createAccountContextForTestScenarios and a sandbox account, then verifies the last invocation has sandboxMode === true and type === PAYMENT_CREATED. In the test environment the bus is DummyEventBus, so publish awaits the listener and the spy fills deterministically. A call to unregisterDomainEventListener in the finally block prevents leakage into other tests.
This test also proves: event production is integrated with the payment creation use case; listening lives through bus registration. The behavior guarded in this file is clear: sandbox isolation and that the PAYMENT_CREATED event is actually published.
// Source: apps/backend/src/__tests__/event-bus/bus.test.ts (summary)
class PaymentCreatedSpyListener extends DomainEventListenerAbstract {
public listenTo = EventType.PAYMENT_CREATED;
public invocations: { sandboxMode: boolean; type: EventType }[] = [];
async handle(
event: PaymentCreatedDomainEvent,
sandboxMode: boolean,
): Promise<void> {
this.invocations.push({ sandboxMode, type: event.type });
}
}
it("should publish PAYMENT_CREATED to sandbox listeners with sandboxMode true", async () => {
const { ib, sandboxAccount } = await createAccountContextForTestScenarios();
const spy = new PaymentCreatedSpyListener(ib);
const subscription =
await ib.infra.sandbox.eventBus.registerDomainEventListener(spy);
try {
const payment = await ib.useCases.payment.create.execute(
{
accountId: sandboxAccount.id,
amount: 100n,
currency: Currency.TRY,
},
true,
new SystemActor(),
);
expect(payment).toBeDefined();
expect(spy.invocations.length).toBeGreaterThanOrEqual(1);
const last = spy.invocations[spy.invocations.length - 1]!;
expect(last.sandboxMode).toBe(true);
expect(last.type).toBe(EventType.PAYMENT_CREATED);
} finally {
await ib.infra.sandbox.eventBus.unregisterDomainEventListener(subscription);
}
});Roadmap: broker abstraction, queue, job queue, and microservices
Today's system offers a clear wake-up pipeline within a single process; these layers will be updated in the future. The goal is an abstracted produce-consume surface behind IDomainEventBus: adapters that can connect to Kafka, RabbitMQ, NATS, Redis Streams, or any broker that fits the need, with consistent, retryable, queued delivery on top; plus a general-purpose job queue so long-running or heavy work runs on workers. Domain event publish becomes a "produce message" step in that world; consumption, offsets, dead-letter queues, and idempotency keys become operational standards.
We think about next steps in four tiers.
First tier - in-domain broadcast and channels. Wiring broadcastTo / broadcastMap into the publish path will allow a single event to map to multiple logical channels or types. For example, the same PaymentCreated event can route to both an "operational webhook" and an "internal analytics" channel; channel-based listener groups, paired with broker and worker, reduce mutual blocking risk.
Second tier - broker and consistent delivery. When multiple nodes run, the in-process bus stays limited to that process. The aim here is to publish events through a central broker and let consumers process them in a scalable, replayable way when needed. The existing IDomainEventBus contract can extend via infra adapters without breaking application code; the use case still calls publish, but the message lives at disk or cluster level.
Third tier - job queue and webhook delivery. For external calls such as webhooks: durable delivery records, state machines, and worker-controlled HTTP retries; the domain event remains only a "create job" trigger. Operational visibility and customer SLA then depend on queue and table SOT, not the event object itself.
Fourth tier - moving domain modules to a microservice architecture. Long term, when bounded contexts split into separate deploy units, events become inter-service boundary objects as much as inter-process ones. Each service keeps its own database and lifecycle, managed with schema and versioning. Today's EventType discipline lays groundwork for that split.
// Forward-looking representative interface - example outside the repo
interface ClusterDomainEventBus {
publish(
event: DomainEventAbstractBase,
options?: { channels?: string[] },
): Promise<void>;
subscribe(
eventTypes: EventType[],
handlerId: string,
onEvent: (envelope: unknown) => Promise<void>,
): Promise<SubscriptionHandle>;
}Note: A separate reflect-metadata-based listener experiment also exists under application/shared/events; the main production path runs through infra ProdEventBus and DomainEventListenerAbstract. Whether the old experiment merges with the new path is a separate refactor topic.
In summary: Hsrcpay's internal domain event system today meets the "something happened, listeners should wake up" need with a dual bus, shared listener registration, the correct emitter via resolveInfra, and type-safe event objects; durable delivery and SOT will take shape in records opened by listeners and, later, in a broker + queue + worker layer. In production, ProdEventBus runs fire-and-forget; in development and tests, DummyEventBus offers awaited delivery through the same interface. As of April 12, 2026, the behavior described here is verifiable in the codebase, especially the PAYMENT_CREATED path and sandbox test. Broker abstraction, job queue, consistent retryable queue, and microservice boundaries are a deliberate next evolution.