Transaction Hooks
Hooks let you register callbacks that run at specific points in the transaction lifecycle. They are accessed via ITransactionHooks, which is registered as a singleton by AddTransactionalServices.
public class OrderService : IOrderService
{
private readonly ITransactionHooks _hooks;
public OrderService(ITransactionHooks hooks) => _hooks = hooks;
[Transactional]
public async Task PlaceOrderAsync(Order order)
{
// ... business logic ...
_hooks.AfterCommit(() => _eventBus.Publish(new OrderPlaced(order.Id)));
}
}
Hook reference
| Hook | Sync | Async | Fires when | Exceptions |
|---|---|---|---|---|
BeforeCommit |
✓ | ✓ | Inside scope, just before scope.Complete() |
Propagate — trigger rollback |
BeforeRollback |
✓ | ✓ | Inside scope, just before dispose on rollback path | Suppressed |
AfterCommit |
✓ | ✓ | After scope is disposed — transaction committed | Propagate to caller |
AfterRollback |
✓ | ✓ | After scope is disposed — transaction rolled back | Suppressed |
AfterCompletion |
✓ | ✓ | After scope is disposed — always fires (commit or rollback) | Suppressed on rollback path; propagate on commit path |
Commit path
BeforeCommit (sync) ← inside scope, throws trigger rollback
BeforeCommit (async) ← inside scope, throws trigger rollback
│
scope.Complete()
scope.Dispose() ← transaction committed
│
AfterCommit (sync) ← outside scope
AfterCommit (async)
AfterCompletion (sync)
AfterCompletion (async)
Rollback path
BeforeRollback (sync) ← inside scope, exceptions suppressed
BeforeRollback (async) ← inside scope, exceptions suppressed
│
scope.Dispose() ← no Complete(), transaction rolled back
│
AfterRollback (sync) ← outside scope, exceptions suppressed
AfterRollback (async)
AfterCompletion (sync)
AfterCompletion (async)
Within each event, sync hooks always execute before async hooks.
BeforeCommit — validate or enrich before persisting
BeforeCommit hooks run inside the open scope, so any database writes they make are part of the same transaction.
[Transactional]
public async Task PlaceOrderAsync(Order order)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
_hooks.BeforeCommit(async () =>
{
// This write is inside the same scope — commits or rolls back with the order.
_db.AuditLog.Add(new AuditEntry("order-saved", order.Id));
await _db.SaveChangesAsync();
});
}
If a BeforeCommit hook throws, the exception propagates and the scope is rolled back instead of committed.
AfterCommit — publish events after the transaction is durable
AfterCommit hooks run after the scope is disposed. This is the correct place to publish integration events — you know the data is committed before any subscriber can react to it.
[Transactional]
public async Task PlaceOrderAsync(Order order)
{
_db.Orders.Add(order);
await _db.SaveChangesAsync();
_hooks.AfterCommit(async () =>
{
await _eventBus.PublishAsync(new OrderPlaced(order.Id));
});
}
AfterRollback — compensating actions
Use AfterRollback to undo out-of-band side effects (cache evictions, external API calls) that happened during the transaction body.
[Transactional]
public async Task ReserveInventoryAsync(int productId, int qty)
{
await _inventoryApi.ReserveAsync(productId, qty);
_hooks.AfterRollback(() =>
{
// Release the external reservation if the transaction rolls back.
_inventoryApi.ReleaseAsync(productId, qty);
});
}
AfterCompletion — cleanup that must always run
AfterCompletion fires on both commit and rollback. Use it for cleanup that must happen regardless of the outcome: clearing caches, releasing resources, updating metrics.
_hooks.AfterCompletion(() => _cache.Invalidate(order.Id));
Async hooks on sync call paths
Async hook overloads (Func<Task>) require the method to return Task, Task<T>, ValueTask, or ValueTask<T>. Registering an async hook inside a synchronous [Transactional] method throws NotSupportedException:
Async hooks cannot be awaited on a synchronous [Transactional] call path.
Change the method return type to Task, Task<T>, ValueTask, or ValueTask<T>.
The check happens at execution time (before any hook runs), so no partial side effects occur.
Nested scope hook isolation
When an inner Required service joins an existing ambient scope, its hooks accumulate in the outer scope's collection and fire when the outer scope commits — not when the inner call returns.
When an inner RequiresNew scope opens, it gets its own isolated hook collection. Hooks registered inside fire when the inner scope commits, independently of the outer scope.
When a Suppress scope runs, Transaction.Current is null, so hook registrations are no-ops — they are silently discarded.