Skip to content

Transactional Outbox

The Transactional Outbox pattern guarantees reliable event publishing by persisting events in the same transaction as business data. If the transaction commits, events are guaranteed to be delivered. If it rolls back, events are discarded along with the data — no inconsistency possible.

Granit delegates the Outbox to Wolverine, which stores messages in a dedicated table within the application database. A background dispatcher relays messages to configured transports after commit.

sequenceDiagram
    participant H as Handler
    participant DB as DbContext
    participant OT as Outbox Table
    participant TX as Transaction
    participant D as Dispatcher
    participant T as Transport
    participant C as Consumer

    H->>DB: UPDATE patients SET ...
    H->>OT: INSERT INTO outbox (SendWebhookCommand)
    H->>TX: COMMIT
    Note over DB,OT: Atomic -- same transaction

    D->>OT: SELECT non-dispatched
    D->>T: Publish (PostgreSQL queue / RabbitMQ)
    D->>OT: DELETE dispatched
    T->>C: Delivery with retry

    Note over H,C: If ROLLBACK -- no message in the Outbox

The Outbox is activated by provider modules (PostgreSQL, SQL Server), not by Granit.Wolverine itself (which remains transport-agnostic).

ComponentFileRole
AddGranitWolverine()src/Granit.Wolverine/Extensions/WolverineHostApplicationBuilderExtensions.csConfigures the bus, retries, behaviors — not the Outbox
GranitWolverinePostgresqlModulesrc/Granit.Wolverine.Postgresql/GranitWolverinePostgresqlModule.csActivates the PostgreSQL Outbox
// IDomainEvent -> local queue (NEVER the Outbox)
opts.PublishMessage<IDomainEvent>().ToLocalQueue("domain-events");
// IIntegrationEvent -> configured transport (with Outbox)
// Routing is configured by the provider module

Handlers returning IEnumerable<T> produce multiple Outbox messages within the same transaction:

HandlerFileMessages produced
WebhookFanoutHandlersrc/Granit.Webhooks/Handlers/WebhookFanoutHandler.csN x SendWebhookCommand (one per active subscription)
ComponentFileRole
RecurringJobSchedulingMiddlewaresrc/Granit.BackgroundJobs/Internal/RecurringJobSchedulingMiddleware.csInserts the next scheduled message into the Outbox before handler execution

The Before() middleware writes the next scheduled message and updates NextExecutionAt in DB. Everything is in the same Outbox transaction. If the handler fails, the rollback also cancels the rescheduling — no duplicates or lost messages.

ProblemSolution
”Fire and forget” after commit loses messages if the process crashesThe Outbox persists the message BEFORE commit, in the same transaction
Dual write (DB + message broker) creates inconsistenciesSingle transaction eliminates the problem
Domain events must not cross service boundariesIDomainEvent is explicitly routed locally, never to the Outbox
Background jobs must reschedule atomicallyRecurringJobSchedulingMiddleware uses the Outbox for atomicity
Webhook fan-out: N notifications for 1 eventIEnumerable<SendWebhookCommand> produces N Outbox messages atomically
// The handler returns messages -- Wolverine persists them in the Outbox
public static class InvoiceCreatedHandler
{
public static IEnumerable<object> Handle(
CreateInvoiceCommand command,
InvoiceDbContext db)
{
Invoice invoice = new()
{
PatientId = command.PatientId,
Amount = command.Amount
};
db.Invoices.Add(invoice);
// SaveChangesAsync is called by Wolverine (auto-transaction)
// These messages are persisted in the Outbox, not sent immediately
yield return new SendInvoiceEmailCommand { InvoiceId = invoice.Id };
yield return new NotifyAccountingEvent { InvoiceId = invoice.Id, Amount = invoice.Amount };
// If the transaction fails -- no message is sent
// If the transaction succeeds -- both messages are guaranteed delivered
}
}