@@ -14,6 +14,7 @@ This document maps the key request flows and domain event propagation patterns a
14146 . [ Referral Application Flow] ( #referral-application-flow )
15157 . [ Withdrawal Flow] ( #withdrawal-flow )
16168 . [ Notification Delivery Flow] ( #notification-delivery-flow )
17+ 9 . [ Subscribing to Domain Events] ( #subscribing-to-domain-events )
1718
1819---
1920
@@ -508,3 +509,121 @@ Target state: Event-driven communication via domain events
508509| Credentials | Blockchain Infra | Service Call | On-chain storage |
509510| Credentials | Notifications | Domain Event | Credential notification |
510511| All Domains | Shared Kernel | Direct Import | Config, errors, middleware, utils |
512+
513+ ---
514+
515+ ## Subscribing to Domain Events
516+
517+ A new domain event needs a registered handler, not a new worker process. The
518+ outbox relay leases every pending event and dispatches it by ` eventType ` to the
519+ handlers registered for that type.
520+
521+ ### 1. Declare the event schema
522+
523+ Add the payload schema in ` src/lib/transactions/event-schema.ts ` . A handler for
524+ an event type with no schema is rejected at startup.
525+
526+ ``` ts
527+ registry .register ({
528+ version: 1 ,
529+ eventType: ' ModuleCompleted' ,
530+ validate : async (payload ) => {
531+ await z .object ({
532+ completionId: z .string ().uuid (),
533+ userId: z .string ().uuid (),
534+ }).parseAsync (payload )
535+ },
536+ })
537+ ```
538+
539+ ### 2. Emit the event in the same transaction as the domain write
540+
541+ ``` ts
542+ await prisma .$transaction (async (tx ) => {
543+ const completion = await tx .completion .create ({ data: ... })
544+
545+ await createOutboxService (prisma ).createEvent (tx , {
546+ aggregateId: completion .id ,
547+ aggregateType: ' Completion' ,
548+ eventType: ' ModuleCompleted' ,
549+ eventVersion: 1 ,
550+ payload: { completionId: completion .id , userId },
551+ source: ' api.module.complete' ,
552+ })
553+
554+ return completion
555+ })
556+ ```
557+
558+ If the transaction rolls back the event disappears with it, so an event can
559+ never describe a write that did not happen.
560+
561+ ### 3. Write the handler
562+
563+ ``` ts
564+ export class RewardOnModuleCompleted implements OutboxEventHandler {
565+ readonly name = ' rewards.on-module-completed'
566+ readonly eventType = ' ModuleCompleted'
567+ readonly eventVersion = 1
568+ readonly maxAttempts = 5
569+
570+ async handle(ctx : OutboxEventHandlerContext ) {
571+ const payload = ctx .payload as ModuleCompletedPayload
572+ await rewardService .grant (payload .userId , payload .completionId )
573+
574+ return { idempotencyKey: ` ${ctx .eventId }:${this .name } ` }
575+ }
576+ }
577+ ```
578+
579+ ` name ` must be unique across the whole registry — it becomes ` JobAttempt.jobType ` .
580+
581+ ** Handlers must be idempotent.** A handler can run more than once for the same
582+ event: after a crash mid-lease, or after an operator replays a dead-lettered
583+ event. Make the side effect an upsert, or guard it on a key derived from
584+ ` ctx.eventId ` .
585+
586+ Throwing from ` handle() ` schedules a retry with exponential backoff. Returning
587+ normally completes the attempt.
588+
589+ ### 4. Register it
590+
591+ Add the handler in ` src/jobs/handler-registrations.ts ` , and add its event type
592+ to ` EMITTED_EVENT_TYPES ` if the application emits it:
593+
594+ ``` ts
595+ registry .register (new RewardOnModuleCompleted ())
596+ ```
597+
598+ ` registerOutboxHandlers() ` throws at startup on a duplicate handler name, on a
599+ handler whose event type has no schema, and on an emitted event type with no
600+ handler — so a missing subscription fails loudly instead of leaving rows PENDING
601+ forever.
602+
603+ ### What the relay guarantees
604+
605+ - One ` JobAttempt ` per (event, handler). Several handlers may subscribe to the
606+ same event type and each is tracked separately.
607+ - An event becomes ` PUBLISHED ` only once ** every** handler for its type has
608+ completed. One failing handler holds the event back without blocking others.
609+ - A handler that exhausts ` maxAttempts ` dead-letters its own job and the event,
610+ leaving every other event type unaffected.
611+ - An event with no registered handler is dead-lettered immediately and logged at
612+ error level, rather than sitting ` PENDING ` unnoticed.
613+
614+ ### Operating dead letters
615+
616+ ``` bash
617+ pnpm outbox:replay list # dead-lettered events and last error
618+ pnpm outbox:replay replay < eventId> ... # reset to PENDING for another pass
619+ ```
620+
621+ Replay resets the dead-lettered ` JobAttempt ` rows and returns the event to
622+ ` PENDING ` ; the relay picks it up on its next tick. Completed handlers are not
623+ re-run, and idempotent handlers make a repeated run harmless.
624+
625+ ### Where it runs
626+
627+ The relay is a queue on the scheduled job runner
628+ (` src/workers/scheduler.worker.ts ` ), registered as ` outbox-relay ` . There is no
629+ per-domain worker process: adding a domain event means adding a handler.
0 commit comments