Skip to main content

Notification Transactions and Save Boundaries

Summary

There is no explicit transaction anywhere in the Notification Service. No BeginTransaction, no TransactionScope and no execution strategy is used. Each SaveChangesAsync is its own implicit transaction, and a single event produces many of them.

Audience

Engineers, architects, QA, support and DevOps engineers.

Transaction boundaries

Each box is a separate implicit transaction. Nothing spans them.

Save inventory

Fifteen save sites exist in the reviewed service: nine in the dispatcher, five in the API handlers and one in the seeder.

SiteWhat it commits
Audit helperOne audit row — called at every pipeline stage
Recipient upsertOne inserted or merged recipient
Message insertOne message row
Delivery successAttempt row plus message state transition
Delivery retryAttempt row plus counter and error
Delivery exhaustedAttempt row plus failed status
Sender missingFailed status and error
Mark processedOne idempotency ledger row
Retry resetPending status and cleared error
API read-stateStatus, timestamp and audit row together
API template create, update, testTemplate or message and attempt rows

Atomicity consequences

Event processing is not atomic. A crash partway through leaves committed messages, committed attempt rows and a committed partial audit trail, but no idempotency ledger row — because the ledger is written last. On redelivery the duplicate check finds nothing and the event is processed again from the beginning, producing duplicate messages and duplicate deliveries for the portion already completed.

This is the most consequential persistence characteristic in the module.

Delivery and state are co-committed but not with the send. The attempt row and the message state transition are added to the tracker and saved together, so they cannot diverge from each other. The provider send, however, has already happened before that save, so a failure to persist after a successful send loses the record of a notification that really was delivered.

Audit rows are independent. Each is its own save, so the audit trail can outlive a failed operation, recording a stage whose effect was rolled back or never committed.

The one multi-entity save

The read-state endpoint is the only site that commits two different entity types in one save: the message status update and its audit row. Every other site commits one entity type, or a message plus its attempt row.

Failure handling

No save is wrapped in a try-catch except inside the delivery loop, whose catch is scoped to the provider send rather than to persistence. A save failure — a constraint violation, a connectivity loss — therefore propagates as an unhandled exception. In the consumer path that causes a negative acknowledgement with requeue; in the API path it surfaces without a defined error contract.

Verified absence

No explicit transaction, no savepoint, no retrying execution strategy, no outbox pattern and no compensating action exists anywhere in the reviewed source.

Classification

Partial.

Requires confirmation

Whether the ledger-written-last ordering is intentional, and whether transactional event processing is planned, require confirmation.

See Also

Keywords

  • Notification database
  • Transactions
  • Draft database documentation

Source References

  • microservices/src/notification-service/Application/NotificationDispatcher.cs
  • microservices/src/notification-service/Api/NotificationEndpoints.cs
  • microservices/src/notification-service/Infrastructure/NotificationDbContext.cs
  • microservices/src/notification-service/Seed/DefaultTemplateSeeder.cs

Revision Information

  • Status: Draft
  • Last reviewed: 2026-07-21
  • Review cycle: Quarterly