Whisper.Outbox.SqlServer
3.0.0
See the version list below for details.
dotnet add package Whisper.Outbox.SqlServer --version 3.0.0
NuGet\Install-Package Whisper.Outbox.SqlServer -Version 3.0.0
<PackageReference Include="Whisper.Outbox.SqlServer" Version="3.0.0" />
<PackageVersion Include="Whisper.Outbox.SqlServer" Version="3.0.0" />
<PackageReference Include="Whisper.Outbox.SqlServer" />
paket add Whisper.Outbox.SqlServer --version 3.0.0
#r "nuget: Whisper.Outbox.SqlServer, 3.0.0"
#:package Whisper.Outbox.SqlServer@3.0.0
#addin nuget:?package=Whisper.Outbox.SqlServer&version=3.0.0
#tool nuget:?package=Whisper.Outbox.SqlServer&version=3.0.0
Whisper
Whisper is a minimal-impact domain event tracking library for .NET.
It lets rich domain models raise domain events with no pollution β no Events property on aggregates, no plumbing in entities, and no domain-level awareness of dispatching or persistence.
Under the hood, Whisper uses AsyncLocal<T> to safely track domain events across both synchronous and asynchronous execution flows.
Events raised during a logical operation (e.g., a MediatR request, an NServiceBus message handler, or an ASP.NET Core request) remain attached to that flow until they are dispatched or persisted by outer layers.
Whisper integrates cleanly with DDD and Clean Architecture by keeping the domain pure and moving event handling to the application and infrastructure layers.
It also provides an optional outbox with MongoDB and SQL Server support, plus drop-in packages for MediatR and integration with NServiceBus unit of work.
π Deep dive article: Minimal-impact domain events
Why Whisper?
Traditional domain event patterns often force you to:
- Add an
Eventscollection to every aggregate and bubble those events up. - Hand-roll async/thread context handling, or rely on brittle statics.
- Mix domain logic with dispatching, messaging, or persistence concerns.
Whisper avoids all of that.
- Clean domain β no
Eventslist and no infrastructure references in your entities. - Async-safe tracking β uses
AsyncLocalso events flow acrossawaits. - Infrastructure integration β MediatR, NServiceBus, MongoDB, and SQL Server support.
- Zero ceremony β a single static call to raise an event from anywhere in your domain.
Packages
| Package | Purpose |
|---|---|
| Whisper | Core tracking logic (Whispers, IDomainEvent, scopes) |
| Whisper.Abstractions | Shared contracts (IWhisperBuilder, IDispatchDomainEvents) |
| Whisper.MediatR | MediatR integration β automatically dispatches raised events after each request |
| Whisper.AspNetCore | AspNetCore integration β automatically dispatches raised events after each request |
| Whisper.Outbox | Outbox infrastructure + background worker and installer |
| Whisper.Outbox.MongoDb | MongoDB outbox store + optional unit-of-work participation via IMongoSessionProvider |
| Whisper.Outbox.SqlServer | SQL Server outbox store + optional unit-of-work participation via IConnectionLeaseProvider |
| Whisper.Outbox.MongoDb.NServiceBus | Adapter to reuse the NServiceBus Mongo storage session |
| Whisper.Outbox.SqlServer.NServiceBus | Adapter to reuse the NServiceBus SQL storage session |
Licensed under MIT.
How it works (high level)
Whisper keeps a per-execution βdomain event scopeβ in an AsyncLocal<T>:
- In your domain, you raise events:
Whispers.About(new OrderApproved(orderId)); - Whisper attaches those events to the current async flow.
- In your application / infrastructure layer, Whisper (or your configured integration) retrieves and dispatches/persists the collected events at the right time β e.g., after a MediatR pipeline completes or via the outbox worker.
Your domain never exposes an events collection and never learns about dispatching, messaging, or persistence.
Core usage
1) Define a domain event
using Whisper;
public sealed record OrderApproved(Guid OrderId) : IDomainEvent;
2) Raise it from anywhere in the domain
using Whisper;
public class Order
{
public Guid Id { get; }
public void Approve()
{
// Domain logic...
Whispers.About(new OrderApproved(Id));
}
}
3) Optional: scopes for isolation
If you want isolation per request/unit of work, create a scope. Events raised inside are collected and can be read/cleared as needed:
using Whisper;
using var scope = Whispers.CreateScope();
// ... domain operations that raise events
var raised = Whispers.GetAndClearEvents(); // events for this scope (and children)
Most users donβt need to manage scopes explicitly β integrations (like MediatR) take care of flushing at the right time.
MediatR integration
When you call b.AddMediatR() inside the Whisper builder, Whisper registers:
- An
IDispatchDomainEventsimplementation that publishes viaIMediator. - A MediatR pipeline behavior that automatically retrieves and dispatches all raised domain events after each request.
using Whisper.Abstractions;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection()
.AddMediatR(c => c.RegisterServicesFromAssemblyContaining<Program>())
.AddWhisper(b =>
{
b.AddMediatR(); // wires up dispatcher + automatic behavior
});
AspNetCore integration
When you call app.UseDomainEventDispatcherMiddleware() on the IApplicationBuilder of your AspNetCore host, Whisper registers:
- A conventional
DomainEventDispatcherMiddlewareimplementation that publishes via the registeredIDispatchDomainEventsimplementations.
using Microsoft.AspNetCore.Builder;
app.UseDomainEventDispatcherMiddleware();
Outbox & transactions
Whisper provides an outbox for reliable, asynchronous dispatch:
- Persists raised events to a durable store.
- A background worker reads pending records and dispatches them via your configured
IDispatchDomainEvents. - Supports MongoDB and SQL Server.
- Optionally participates in your unit of work via very small provider abstractions.
Unit-of-work participation is opt-in, enabled explicitly via builder methods β ob.UseConnectionLeaseProvider<T>() (SQL Server) and ob.UseMongoSessionProvider<T>() (MongoDB). Ownership follows origin: Whisper disposes only what it creates, and it creates only when no provider is registered.
- No provider registered: Whisper manages its own database access. For SQL Server it opens and disposes its own
SqlConnectionper operation, with no transaction; for MongoDB it uses plain writes with noIClientSessionHandle(and therefore no replica set requirement). Outbox records commit independently of any host transaction. - Provider registered: the host owns the connection/transaction/session entirely β Whisper only uses what the provider yields and never opens, begins, commits, rolls back, or disposes it.
The background worker always reads and dispatches after the unit of work completes β deliberately outside it.
That is at-least-once publishing: the point of an outbox.
MongoDB outbox
using Whisper.Abstractions;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection()
.AddMediatR(c => c.RegisterServicesFromAssemblyContaining<Program>())
.AddWhisper(b =>
{
b.AddMediatR();
b.AddOutbox(ob =>
{
ob.AddMongoDb(new()
{
ConnectionString = "mongodb://localhost:27017",
DatabaseName = "appdb",
// CollectionName = "outboxrecords" // optional, default shown
});
});
});
AddMongoDbalso has an overload taking aFunc<IServiceProvider, MongoDbOutboxConfiguration>for configuration resolved from DI.
IMongoSessionProvider (optional) lets the dispatch-side Add participate in your MongoDB session/transaction:
using MongoDB.Driver;
public interface IMongoSessionProvider
{
public bool IsInTransaction => Session is not null;
IClientSessionHandle? Session { get; }
}
There are no
Commit/Abortmethods on this interface.
WhenSessionisnull, Whisper writes without a session; otherwise it passes the session to its writes and the host owns it β Whisper never starts, commits, aborts, or disposes it.
Default β no session
AddMongoDb alone registers no provider. Outbox records are written as plain inserts β no IClientSessionHandle, no replica set requirement β and commit independently of any host transaction.
b.AddOutbox(ob => ob.AddMongoDb(new() { /* ... */ }));
Host-managed session
For the common βmy unit of work starts the sessionβ case, Whisper ships a built-in provider pair. Register it with UseHostManagedMongoSession(), then hand your session over via IMongoSessionProviderInitializer after starting it:
b.AddOutbox(ob =>
{
ob.AddMongoDb(new() { /* ... */ });
ob.UseHostManagedMongoSession();
});
using Microsoft.Extensions.DependencyInjection;
using MongoDB.Driver;
using Whisper.Outbox.MongoDb;
// Inside your unit of work, after starting the session:
using var session = await mongoClient.StartSessionAsync(cancellationToken: ct);
session.StartTransaction();
serviceProvider.GetRequiredService<IMongoSessionProviderInitializer>().Initialize(session);
// ... domain operations β the outbox `Add` now joins `session`
await session.CommitTransactionAsync(ct); // the host owns commit/abort
A scope that never calls
Initializegets plain (session-less) writes.
NServiceBus storage session
b.AddOutbox(ob =>
{
ob.AddMongoDb(new() { /* ... */ });
ob.UseNServiceBusStorageSession(); // IMongoSessionProvider backed by the NServiceBus storage session
});
Custom unit of work
Return the session your unit of work already owns; the unit of work keeps ownership of commit/abort:
using MongoDB.Driver;
using Whisper.Outbox.MongoDb;
public sealed class UnitOfWorkMongoSessionProvider(MyUnitOfWork unitOfWork) : IMongoSessionProvider
{
public IClientSessionHandle? Session => unitOfWork.Session;
}
b.AddOutbox(ob =>
{
ob.AddMongoDb(new() { /* ... */ });
ob.UseMongoSessionProvider<UnitOfWorkMongoSessionProvider>();
});
UseMongoSessionProvideralso has an overload taking aFunc<IServiceProvider, TProvider>factory:
ob.UseMongoSessionProvider(sp => new UnitOfWorkMongoSessionProvider(sp.GetRequiredService<MyUnitOfWork>()));
RegisteringIMongoSessionProviderdirectly in DI is honored too, but the builder method is the intended path.
SQL Server outbox
using Whisper.Abstractions;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection()
.AddMediatR(c => c.RegisterServicesFromAssemblyContaining<Program>())
.AddWhisper(b =>
{
b.AddMediatR();
b.AddOutbox(ob =>
{
ob.AddSqlServer(new()
{
ConnectionString = "Server=.;Database=AppDb;Trusted_Connection=True;Encrypt=False;",
SchemaName = "outbox", // optional, default "dbo"
TableName = "OutboxRecords" // optional, default "outboxrecords"
});
});
});
IConnectionLeaseProvider (optional) lets the dispatch-side Add use your SQL connection + transaction:
using Microsoft.Data.SqlClient;
public interface IConnectionLeaseProvider
{
ValueTask<ConnectionLease> Provide(CancellationToken cancellationToken);
}
public sealed record ConnectionLease(SqlConnection Connection, SqlTransaction? Transaction = null);
Ownership by origin: Whisper disposes only what it creates, and it creates only when no provider is registered.
ConnectionLeaseis pure data β everything a provider yields belongs to the host; Whisper only uses the connection and transaction and never opens, commits, rolls back, or disposes them.
Default β own connection per operation
AddSqlServer alone registers no provider. Without one, Whisper opens a fresh SqlConnection per operation, uses no transaction, and disposes the connection itself. Outbox records commit independently of any host transaction.
b.AddOutbox(ob => ob.AddSqlServer(new() { /* ... */ }));
TransactionScope β zero-code opt-in
On this no-provider path, the SqlConnection Whisper opens enlists in an ambient System.Transactions.TransactionScope by default (Enlist=true). Wrap the use case in a scope and outbox writes plus your own SQL join one transaction that the host completes:
using var tx = new TransactionScope(TransactionScopeAsyncFlowOption.Enabled);
// ... host SQL work + domain operations that raise events
tx.Complete();
TransactionScopeAsyncFlowOption.Enabledis required with async code. Sequential opens against the same connection string reuse the transaction-affiliated pooled connection, so a single SQL Server does not escalate to MSDTC β but concurrent connections inside the scope can escalate to a distributed transaction.
NServiceBus storage session
b.AddOutbox(ob =>
{
ob.AddSqlServer(new() { /* ... */ });
ob.UseNServiceBusStorageSession(); // IConnectionLeaseProvider backed by the NServiceBus storage session
});
Custom unit of work
Return the connection and transaction your unit of work already owns; the unit of work keeps ownership of commit/rollback and disposal:
using Whisper.Outbox.SqlServer;
public sealed class UnitOfWorkConnectionLeaseProvider(MyUnitOfWork unitOfWork) : IConnectionLeaseProvider
{
public ValueTask<ConnectionLease> Provide(CancellationToken cancellationToken)
=> ValueTask.FromResult(new ConnectionLease(unitOfWork.Connection, unitOfWork.Transaction));
}
b.AddOutbox(ob =>
{
ob.AddSqlServer(new() { /* ... */ });
ob.UseConnectionLeaseProvider<UnitOfWorkConnectionLeaseProvider>();
});
Warning β every scope, no fallback: a registered
IConnectionLeaseProvideris consulted for every store operation, including the background workerβs own polling scopes, where no unit of work is active. There is deliberately no fallback. Your provider must return a usable, open connection in every scope β when no unit of work is active, open one yourself (e.g., from configuration); otherwise the worker can never read or dispatch outbox records. This applies to the SQL provider only: the Mongo equivalent handles it naturally (Sessionisnullβ plain writes).
UseConnectionLeaseProvideralso has an overload taking aFunc<IServiceProvider, TProvider>factory:
ob.UseConnectionLeaseProvider(sp => new UnitOfWorkConnectionLeaseProvider(sp.GetRequiredService<MyUnitOfWork>()));
RegisteringIConnectionLeaseProviderdirectly in DI is honored too, but the builder method is the intended path.
NServiceBus adapters
Re-use the NServiceBus storage session as the transaction context. UseNServiceBusStorageSession() may be called before or after AddMongoDb / AddSqlServer β registration is order-independent. Internally it routes through UseMongoSessionProvider / UseConnectionLeaseProvider, so the same ownership rule applies: NServiceBus owns the session/connection; Whisper only uses it.
MongoDB
b.AddOutbox(ob =>
{
ob.AddMongoDb(...);
ob.UseNServiceBusStorageSession(); // IMongoSessionProvider from NServiceBus
});
SQL Server
b.AddOutbox(ob =>
{
ob.AddSqlServer(...);
ob.UseNServiceBusStorageSession(); // IConnectionLeaseProvider from NServiceBus
});
Worker configuration & retry semantics
The background worker polls the store on a fixed interval. Configure it via ConfigureWorker:
b.AddOutbox(ob =>
{
ob.AddSqlServer(new() { /* ... */ });
ob.ConfigureWorker(w =>
{
w.BatchSize = 10; // default
w.PollingIntervalMs = 1000; // default
w.MaxRetries = 3; // default
});
});
Per record in a batch:
- Success: the record is marked
DispatchedAtUtc. - Deserialization failure is permanent: the record is marked
FailedAtUtcimmediately β no retry. - Dispatch failure is transient:
Retriesis incremented and the record is retried on a later poll; onceRetries + 1 >= MaxRetries, it is markedFailedAtUtc.
The worker loop catches and logs all errors and keeps polling.
Failed records stay in the store β there is no automatic cleanup. Monitor and purge rows/documents with
FailedAtUtcset yourself.
Serializer configuration
Domain events are serialized with System.Text.Json. Use ConfigureSerializer to register JsonConverters for value types (e.g., readonly record structs) that System.Text.Json cannot deserialize using the default parameterless constructor:
b.AddOutbox(ob =>
{
ob.AddSqlServer(new() { /* ... */ });
ob.ConfigureSerializer(s => s.Converters.Add(new MyValueTypeConverter()));
});
Startup & installation
AddOutbox registers an installer hosted service that prepares the store at startup:
- SQL Server: creates the schema (if missing) and the table with a clustered primary key on
Id, UTC check constraints, and the filtered indexIX_Outbox_Undispatched_ById. - MongoDB: creates the partial index
ix_outbox_undispatched_by_idon the outbox collection.
Dispatches issued before installation completes are gated, not failed β they wait until the installer signals completion. The background worker waits the same way before its first poll.
UUID generation
AddOutbox registers a default IUuidProvider that generates database-friendly UUIDs (via UUIDNext); AddSqlServer deterministically replaces it with a SQL Server-specific provider that generates sequential GUIDs, friendly to the clustered primary key.
To override it, implement IUuidProvider (Guid Provide()) and register your implementation after the AddWhisper(...) call so it wins over the backendβs replacement:
services.AddWhisper(b => b.AddOutbox(ob => ob.AddSqlServer(...)));
services.Replace(ServiceDescriptor.Transient<IUuidProvider, MyUuidProvider>());
Outbox records are read and dispatched in
Idorder, and on SQL ServerIdis the clustered primary key β a non-sequential custom provider changes dispatch ordering behavior and fragments the clustered index.
Clean Architecture fit
Domain
References onlyWhisper. Raises events viaWhispers.About(...).
NoEventscollection, no leakage of domain events, no knowledge of dispatching or outbox.Application
Orchestrates operations; MediatR behavior (from Whisper.MediatR) automatically flushes events.Infrastructure
Configures outbox storage (Mongo/SQL), transaction providers, and messaging integrations (e.g., NServiceBus).Presentation/API
AspNetCore middleware to scope domain events per request.
AddWhisper API (DI)
Whisper uses a tiny builder to register components.
services.AddWhisper(b =>
{
b.AddMediatR();
b.AddOutbox(ob =>
{
ob.AddMongoDb(...);
// or:
// ob.AddSqlServer(...);
// optional:
// ob.UseNServiceBusStorageSession();
});
});
Signature
public static IServiceCollection AddWhisper(
this IServiceCollection services,
Action<Whisper.Abstractions.IWhisperBuilder> configure);
IWhisperBuilder exposes the underlying IServiceCollection so you can extend/customize the setup.
Core API reference
Whispers (Whisper)
| Method | Description |
|---|---|
IDisposable CreateScope() |
Creates an ambient scope (nestable). |
void About(IDomainEvent domainEvent) |
Raises a domain event from anywhere in the domain. |
IDomainEvent[] Peek() |
Inspect currently raised events without clearing them. |
IDomainEvent[] GetAndClearEvents() |
Retrieve and clear the collected events for the current (deepest) scope. |
IDispatchDomainEvents (Whisper.Abstractions)
public interface IDispatchDomainEvents
{
Task Dispatch(IDomainEvent domainEvent, CancellationToken cancellationToken);
Task Dispatch(IDomainEvent[] domainEvents, CancellationToken cancellationToken);
}
Whisper.MediatR provides an implementation that publishes via
IMediator, and includes an automatic behavior that flushes raised events after each pipeline execution.
FAQ (quick)
Do I need to add an Events list to my aggregates?
No. Raise events with Whispers.About(...) and let Whisper collect them.
Is it safe across async/await?
Yes. Whisper uses AsyncLocal<T> to keep events bound to the current async execution flow.
How do duplicates get handled?
Whisper does not attempt to deduplicate; if you need deduplication, implement it at your dispatcher/consumer side. Outbox publishing is at-least-once: a record that was dispatched successfully but not yet marked (e.g., a crash between the dispatch and SetDispatchedAt) is re-dispatched on a later poll β consumers must be idempotent.
Who commits/rolls back the DB transaction for the outbox?
You do β if there is one. The rule: Whisper disposes only what it creates, and it creates only when no provider is registered. Without a provider there is no host transaction: Whisper opens and disposes its own SqlConnection per operation (SQL Server) or performs plain writes (MongoDB), and outbox records commit independently. With a provider registered (UseConnectionLeaseProvider / UseMongoSessionProvider), the host owns the connection/transaction/session entirely β Whisper only uses them and never begins, commits, rolls back, or disposes anything. The worker dispatches after your unit of work completes (at-least-once).
Does MediatR require a custom behavior from me?
No. When you use b.AddMediatR(), Whisper adds its own behavior that dispatches raised events automatically.
Summary
- Minimal domain impact: raise events anywhere, no aggregate
Eventslist. - Async-safe: powered by
AsyncLocal<T>. - MediatR auto-dispatch: built-in behavior flushes events after each request.
- Outbox ready: MongoDB & SQL Server with transaction participation.
- NServiceBus adapters: reuse existing storage sessions.
- Clean Architecture aligned: domain stays pure; infrastructure handles dispatch/persistence.
Whisper β domain events without domain pollution.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 was computed. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net8.0
- Microsoft.Data.SqlClient (>= 6.1.1 && < 7.0.0)
- Whisper.Outbox (>= 2.0.0)
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Whisper.Outbox.SqlServer:
| Package | Downloads |
|---|---|
|
Whisper.Outbox.SqlServer.NServiceBus
Integrate into the Unit-Of-Work of NServiceBus with SqlServer persistence. |
GitHub repositories
This package is not used by any popular GitHub repositories.