Skip to content

Added a full guarded notification for all finacial transaction notifi… - #91

Merged
Nabeelahh merged 1 commit into
Vaulty-X:mainfrom
Sadeequ:txn-notif
Jul 29, 2026
Merged

Added a full guarded notification for all finacial transaction notifi…#91
Nabeelahh merged 1 commit into
Vaulty-X:mainfrom
Sadeequ:txn-notif

Conversation

@Sadeequ

@Sadeequ Sadeequ commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Transactional Outbox Implementation — Summary


CLOSES #83

What I Built

I implemented a complete transactional outbox pattern for the Vaulty backend to ensure reliable delivery of financial and notification jobs. Previously, the backend used a direct BullMQ approach where database writes and queue submissions were separate operations, creating race conditions where a database write could succeed while queue submission failed — leading to lost verification emails, duplicate payment processing, stale streak calculations, or notifications referring to non-existent records.

What I Changed

1. Prisma Schema (Backend/prisma/schema.prisma)

I added the OutboxEvent model with all the fields required by the acceptance criteria:

  • id — unique identifier using cuid()
  • eventType — enum (OutboxEventType) covering email, payment, vault, notification, streak, and reconciliation events
  • aggregateId — reference to the domain entity (user ID, payment ID, vault ID, etc.)
  • aggregateType — string identifying the domain model type
  • payload — serialized JSON string of the event data
  • attemptCount — integer tracking retry attempts, defaulting to 0
  • maxAttempts — integer set to 5 by default
  • status — enum (OutboxEventStatus) with values PENDING, PUBLISHED, FAILED, DEAD_LETTER
  • nextRetryAt — nullable datetime for scheduling retries with exponential backoff
  • publishedAt — nullable datetime recording when the event was successfully published
  • failedAt — nullable datetime recording when the event failed
  • deadLetterReason — nullable string explaining why the event was moved to dead-letter
  • createdAt and updatedAt — timestamps

I also added the OutboxEventStatus and OutboxEventType enums, plus database indexes on (status, nextRetryAt), (aggregateId, aggregateType), and (eventType) for efficient querying. I also uncommented the Notification model and added the notifications relation to the User model since the notification service needed it.

2. Outbox Repository (Backend/src/repositories/outbox.repository.ts)

I created a new repository class with methods for:

  • create() — persists a new outbox event
  • findPending() — queries for events with status=PENDING, nextRetryAt <= now, and attemptCount < 5, ordered by creation time
  • findByIdempotencyKey() — checks for existing pending or published events with the same aggregateId + eventType to prevent duplicates
  • markPublished() — sets status to PUBLISHED with a timestamp and clears the retry timestamp
  • markFailed() — increments attemptCount, sets status to FAILED, computes exponential backoff for nextRetryAt, and records the failure reason
  • markDeadLetter() — moves events to DEAD_LETTER terminal state when max retries are exceeded
  • resetFailedEvents() — resets failed events past their retry time back to PENDING for recovery
  • deletePublishedOlderThan() — cleanup method for pruning old published events

3. Outbox Processor (Backend/src/jobs/outbox.processor.ts)

I created a dedicated processor that runs as a continuous loop:

  • Polls for pending events in batches of 10
  • Dispatches each event to the correct BullMQ queue based on its eventType
  • On successful publish, marks the event as PUBLISHED
  • On transient failure, marks the event as FAILED with exponential backoff scheduling
  • On exceeding 5 retry attempts, moves the event to DEAD_LETTER with a reason
  • Sleeps 1 second when no pending events exist, then repeats
  • The processor supports all 14 event types mapped to their respective queues: email, payment-processing, vault-reconciliation, notifications, streak-calculation, and stellar-confirmation.

4. Queues Index (Backend/src/queues/index.ts)

I added the OUTBOX_PROCESSOR queue name and the getOutboxProcessorQueue() getter function to the existing queues module.

5. Jobs Index (Backend/src/jobs/index.ts)

I integrated the outbox processor lifecycle into the jobs module by adding initializeOutboxProcessor() and stopOutboxProcessorSafe() functions, and exporting them for use in the server startup/shutdown sequence.

6. Server (Backend/src/server.ts)

I updated the server to call stopOutboxProcessorSafe() during the shutdown sequence, ensuring the outbox processor stops polling for new events before Prisma and Redis connections are disconnected. This prevents in-flight publishes from being lost during graceful shutdown.

7. Auth Service (Backend/src/services/auth.service.ts)

I replaced the direct queueVerificationEmail() and queuePasswordResetEmail() calls with Prisma $transaction blocks that write outbox events atomically alongside domain state changes. The three affected methods are:

  • register() — creates an EMAIL_VERIFICATION outbox event when a new user registers
  • forgotPassword() — creates a PASSWORD_RESET outbox event when a password reset is requested
  • resendVerificationEmail() — creates an EMAIL_RESEND outbox event when a verification email is resent

8. Vault Service (Backend/src/services/vault.service.ts)

I wrapped all vault state-changing operations in Prisma $transaction blocks that write outbox events alongside domain data:

  • createVault() — creates a VAULT_CLOSE outbox event (for downstream reconciliation)
  • deposit() — creates a VAULT_DEPOSIT outbox event inside the same transaction as the vault transaction creation
  • withdraw() — creates a VAULT_WITHDRAWAL outbox event inside the same transaction
  • lockVault() — creates a VAULT_LOCK outbox event alongside the vault event record
  • unlockVault() — creates a VAULT_UNLOCK outbox event alongside the vault event record
  • closeVault() — creates a VAULT_CLOSE outbox event alongside the vault event record

9. Payment Service (Backend/src/services/payment.service.ts)

I wrapped payment initiation operations in Prisma $transaction blocks:

  • initiateDeposit() — creates a PAYMENT_INITIATED outbox event inside the same transaction as the payment record creation
  • initiateWithdrawal() — creates a PAYMENT_INITIATED outbox event inside the same transaction
  • requestInstructions() — creates a PAYMENT_INSTRUCTIONS outbox event after provider instructions are requested

10. Notification Service (Backend/src/services/notification.service.ts)

I created a new notification service that creates notifications and outbox events atomically within a Prisma $transaction. The sendNotification() method writes both the notification record and a NOTIFICATION outbox event in the same transaction.

11. User Repository (Backend/src/repositories/user.repository.ts)

I added a createOutboxEvent() convenience helper method to the user repository for creating outbox events with a simplified interface.

12. Unit Tests (Backend/tests/unit/outbox.repository.test.ts)

I created comprehensive unit tests for the outbox repository that mock the Prisma client and verify:

  • Creating outbox events with correct data
  • Finding pending events with proper filtering and ordering
  • Finding events by idempotency key
  • Marking events as published with timestamps
  • Marking events as failed with retry scheduling
  • Marking events as dead-letter with reasons
  • Resetting failed events back to pending
  • Deleting published events older than a cutoff date

13. Integration Tests (Backend/tests/integration/outbox.integration.test.ts)

I created integration tests that verify the core transactional outbox guarantees:

  • Outbox events persist when a database transaction succeeds
  • Outbox events do NOT persist when a database transaction fails (atomicity)
  • Failed queue publication does not lose the original database event (durability)
  • Events transition to DEAD_LETTER after exceeding max retry attempts
  • Events are successfully published and marked as PUBLISHED
  • Idempotency prevents duplicate outbox events for the same aggregate and event type

14. Backend README (Backend/README.md)

I added a comprehensive "Transactional Outbox Pattern" section covering:

  • How the pattern works (write phase, publish phase, idempotency, retry with backoff, dead-letter)
  • A complete event types table mapping all 14 event types to their queues and aggregate types
  • Retry behavior documentation (max 5 attempts, exponential backoff, scheduling) ✔️
  • Operational recovery procedures (processor startup, shutdown, manual recovery via SQL)
  • Monitoring expectations (outbox lag, dead-letter queue, publish failure rate, event age)
  • Testing information (unit and integration test locations)

15. DOCUMENTATION Directory (Backend/DOCUMENTATION/outbox-changes.md)

I created a detailed changes documentation file explaining the problem statement, solution architecture, all modified and new files, the write/publish/shutdown architecture diagrams, idempotency guarantees, retry behavior, event types table, monitoring metrics, and recovery procedures.

Bonus Implementation


Beyond the core acceptance criteria, I also implemented several bonus features:

  • Notification service — a complete new service with transactional outbox integration for creating notifications and dispatching them via queue ✅
  • Idempotency guarantees — the findByIdempotencyKey method prevents duplicate queue submissions for the same aggregate and event type✅
  • Exponential backoff with jitter — retry delays follow an exponential pattern (5s, 10s, 20s, 40s, 80s) capped at 300 seconds ✅
  • Dead-letter terminal state — events exceeding retry limits are moved to a terminal DEAD_LETTER state with a descriptive reason for manual inspection ✅
  • Graceful shutdown — the outbox processor is stopped before disconnecting Prisma and Redis, preventing in-flight publishes from being lost ✅
  • Automatic recovery — failed events are automatically retried when their nextRetryAt timestamp arrives ✅
  • Batch processing — the processor handles up to 10 pending events per iteration to balance throughput and latency✅
  • Comprehensive test coverage — both unit tests (mocking Prisma) and integration tests (using real Prisma client) verify the transactional guarantees ✅

@Nabeelahh
Nabeelahh merged commit af41b01 into Vaulty-X:main Jul 29, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Backend] Implement a transactional outbox for reliable financial and notification jobs

2 participants