MessagingKit.Outbox
0.3.0
See the version list below for details.
dotnet add package MessagingKit.Outbox --version 0.3.0
NuGet\Install-Package MessagingKit.Outbox -Version 0.3.0
<PackageReference Include="MessagingKit.Outbox" Version="0.3.0" />
<PackageVersion Include="MessagingKit.Outbox" Version="0.3.0" />
<PackageReference Include="MessagingKit.Outbox" />
paket add MessagingKit.Outbox --version 0.3.0
#r "nuget: MessagingKit.Outbox, 0.3.0"
#:package MessagingKit.Outbox@0.3.0
#addin nuget:?package=MessagingKit.Outbox&version=0.3.0
#tool nuget:?package=MessagingKit.Outbox&version=0.3.0
MessagingKit
Transactional outbox and inbox for .NET 10, on EF Core and PostgreSQL.
Reference it from a module and that module can send and receive durably. Messages between modules in one host travel over the same outbox → transport → inbox seam they would use across a network, so moving a module into its own deployment later changes one registration and no module code.
The framework knows nothing about brokers, HTTP, or any other transport. It owns durability and the state machine; you implement IMessageTransport and plug in whatever moves the bytes. An in-process transport ships in the box.
The problem
You cannot atomically write to your database and talk to another system:
db.Invoices.Add(invoice);
await db.SaveChangesAsync(); // committed
await broker.PublishAsync(msg); // process dies here — invoice exists, nobody was told
Swap the order and you get the opposite bug: the message goes out, the transaction rolls back, and you've announced an invoice that does not exist.
The outbox closes that gap by making the intent to send part of the same transaction. The inbox closes the matching gap on the receiving side, where a retried delivery would otherwise be processed twice.
Outbox means don't lose it. Inbox means don't do it twice. A broker replaces neither — it only carries the message in between.
Packages
| Package | Namespace | Contents |
|---|---|---|
MessagingKit |
MessagingKit |
AddMessaging() — outbox, inbox, and in-process delivery in one registration |
MessagingKit.Abstractions |
MessagingKit.Abstractions |
MessageEnvelope, IMessageTransport, IMessageHandler<T>, serializer, type registry |
MessagingKit.Outbox |
MessagingKit.Outbox |
IOutbox.Add, EF model config, SKIP LOCKED dispatcher, retry and dead-lettering |
MessagingKit.Inbox |
MessagingKit.Inbox |
durable deduplication, background processor, handler dispatch, retry and dead-lettering |
MessagingKit.InProcess |
MessagingKit.InProcess |
InProcessTransport — delivers outbox rows straight into the inbox |
MessagingKit.Testing |
MessagingKit.Testing |
DrainMessagingAsync() — run both halves to completion inside a test |
Take MessagingKit for the whole thing, or the halves on their own if you only send or only receive. Each package is one namespace, so one using per package brings in everything it exposes: the contracts (IMessageHandler<T>, [Message], AddMessageHandler, MessagingKitInstrumentation) are in MessagingKit.Abstractions, and the outbox's IOutbox, OutboxMessage and OutboxOptions are all in MessagingKit.Outbox.
Modules
A module registers what it handles. Nothing else in the host needs to know:
A module never names the host's DbContext — it only declares what it sends and handles, so it depends on MessagingKit.Abstractions alone:
// inside the email module
public static IServiceCollection AddEmailModule(this IServiceCollection services) =>
services.AddMessageHandler<SendEmail, SendEmailHandler>();
// inside the billing module — sends, does not handle
services.AddMessageContract<SendEmail>();
The host owns the context, declares it once, and wires the modules:
builder.Services.AddMessaging<AppDbContext>(builder.Configuration);
builder.Services.AddEmailModule();
builder.Services.AddBillingModule();
Order does not matter — both sides only add to the service collection.
For a single-module host the fluent form does the same thing in one statement:
builder.Services.AddMessaging<AppDbContext>(builder.Configuration)
.Handles<SendEmail, SendEmailHandler>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.AddMessaging(); // messaging.outbox + messaging.inbox
}
The tables land in your DbContext, so dotnet ef migrations add picks them up in your own migration history — the package ships no migrations of its own.
AddMessaging is safe to call once per module. The dispatcher and processor are registered once no matter how many modules call it, and in-process delivery is the default transport.
Billing then sends inside its own transaction, and the email module handles it:
db.Invoices.Add(invoice);
outbox.Add(new SendEmail(customer.Email, "Your invoice"));
await db.SaveChangesAsync(); // both rows commit, or neither does
Message names
A message carries a name on the wire. It is derived from the type — SendEmail becomes send-email, HTTPRequest becomes http-request — so Handles<TMessage, THandler>() registers the same name for the sender and the handler and they cannot drift apart.
Renaming the class renames the message, which orphans rows already queued. Pin the name on anything in production:
[Message("email.send.v2")]
public record SendEmail(string To, string Subject);
The string overloads are still there when you want to be explicit: AddMessage<T>("name"), AddHandler<T, THandler>("name").
Transports and routing
In-process delivery is the default. Route specific types elsewhere as modules move out of the host:
services.AddMessaging<AppDbContext>(config)
.Handles<SendEmail, SendEmailHandler>() // stays local
.UseTransportFor<BrokerTransport, InvoiceIssued>(); // this one goes to the broker
Or set a different default and keep only some types local:
.UseInProcessTransport("send-email") // named types stay in the host
.UseTransport<BrokerTransport>() // everything else goes to the broker
A routing key matches either a message's destination or its type, so a module can address another module directly and override the type's usual route:
outbox.Add(new SendEmail(...), destination: "email-module");
.UseTransport<BrokerTransport>("email-module") // everything addressed there
.UseInProcessTransport("send-email") // everything of this type
Resolution runs destination first, then message type, then the default, then any IMessageTransport registered directly in DI. A message with no route at all is dead-lettered immediately rather than retried — a missing registration is a wiring bug, and retrying it for ten attempts only buries the cause.
Implementing a transport is one method:
public sealed class BrokerTransport(IBrokerClient broker) : IMessageTransport
{
public async Task<Result> SendAsync(MessageEnvelope envelope, CancellationToken ct = default)
{
try
{
await broker.PublishAsync(envelope.Destination ?? envelope.Type, envelope.Payload, ct);
return Result.Success();
}
catch (BrokerException ex)
{
return Error.Transient("broker.unreachable", ex.Message);
}
}
}
Transports return a ResultingKit Result. Only an ErrorKind.Transient error is retried; return any other kind (Error.Validation, Error.Failure, …) for failures retrying cannot fix — a malformed destination, a rejected payload. Those skip the retry ladder and go straight to Dead.
Receiving
Handlers take the deserialized message plus a MessageContext carrying the message id, type, attempt count, and headers:
public sealed class SendEmailHandler(IEmailClient client) : IMessageHandler<SendEmail>
{
public Task HandleAsync(SendEmail message, MessageContext context, CancellationToken ct = default) =>
client.SendAsync(message.To, message.Subject, ct);
}
With InProcessTransport the outbox hands messages to the inbox for you. When something else receives them — a broker subscriber, an HTTP endpoint — call the inbox yourself:
if (await inbox.TryStoreAsync(envelope, ct))
{
// stored; the processor will handle it
}
// false means already seen — ack and move on
TryStoreAsync is the deduplication point: the message id is the primary key, so a redelivery cannot create a second row. Acknowledge the transport immediately; the background InboxProcessor resolves IMessageHandler<T>, runs it, and marks the row Processed. A throwing handler is retried with backoff, then dead-lettered.
The inbox runs a handler once per message, but a retry runs it again after a side effect may already have happened — an HTTP call that succeeded but whose response was lost. When the handler calls something that accepts an idempotency key, derive the key from context.MessageId: it is the same on every attempt and unique per message. MailingKit does exactly this for Resend.
À la carte
Outbox and inbox work on their own if you do not want both:
builder.Services.AddOutbox<AppDbContext>(builder.Configuration)
.AddMessage<SendEmail>()
.UseTransport<BrokerTransport>();
builder.Services.AddInbox<AppDbContext>(builder.Configuration)
.AddHandler<SendEmail, SendEmailHandler>();
modelBuilder.AddOutbox(); // messaging.outbox
modelBuilder.AddInbox(); // messaging.inbox
Telemetry
MessagingKit instruments itself the way ASP.NET Core and HttpClient do: spans on an ActivitySource, metrics on a Meter from the container's IMeterFactory, and nothing but System.Diagnostics underneath. No OpenTelemetry package is referenced; your host opts in by name:
using MessagingKit.Abstractions;
builder.Services.AddOpenTelemetry()
.WithTracing(t => t.AddSource(MessagingKitInstrumentation.ActivitySourceName))
.WithMetrics(m => m.AddMeter(MessagingKitInstrumentation.MeterName));
Names follow the current OpenTelemetry messaging semantic conventions where the meaning matches; everything MessagingKit-specific is under messagingkit.. messaging.system is always messagingkit.
Tracing
Trace context is captured when a message is staged and carried in its headers, so the send and the handling that follows belong to the caller's trace rather than two unrelated ones.
| Span | Kind | Started by |
|---|---|---|
send {type} |
Client |
the dispatcher, once per delivery attempt |
process {type} |
Consumer |
the processor, once per handling attempt — including rows it cannot dispatch |
Both are parented to the context captured at staging. The send span is a Client rather than a Producer because it is not that creation context, which the conventions reserve Producer for. Spans are named by the message type: the conventions name them by destination, but a MessagingKit destination is whatever the caller passed, and span names must stay low-cardinality.
| Attribute | On | Value |
|---|---|---|
messaging.system |
both | messagingkit |
messaging.operation.name / messaging.operation.type |
both | send / send, process / process |
messaging.message.id |
both | the message id |
messaging.destination.name |
send | the destination passed to IOutbox.Add, when there is one |
messagingkit.message.type |
both | the wire name, e.g. send-email |
messagingkit.message.attempt |
both | the attempt number, from 1 |
messagingkit.outcome |
both | as on the metrics below |
error.type |
failed attempts | as on the metrics below |
A failed attempt also sets the span status to Error with the failure message, and a thrown exception is recorded as an exception event.
Metrics
| Instrument | Type | Unit | Counts / measures |
|---|---|---|---|
messaging.client.sent.messages |
Counter | {message} |
One per outbox delivery attempt, after its row is marked |
messaging.client.operation.duration |
Histogram | s |
Wall time of that attempt: resolving the transport, sending, marking the row |
messaging.client.consumed.messages |
Counter | {message} |
One per inbox handling attempt, after its row is marked |
messaging.process.duration |
Histogram | s |
Wall time of that attempt: resolving the handler, running it, marking the row |
messagingkit.inbox.received.messages |
Counter | {message} |
One per IInbox.TryStoreAsync call that reached a verdict |
messagingkit.outbox.messages |
ObservableUpDownCounter | {message} |
Outbox rows by messagingkit.message.status: pending (waiting or in flight), dead |
messagingkit.inbox.messages |
ObservableUpDownCounter | {message} |
Inbox rows by messagingkit.message.status: pending (waiting or in flight), dead |
The first five carry messagingkit.message.type (the wire name, as on the spans) and messagingkit.outcome:
| Instrument | messagingkit.outcome |
|---|---|
outbox (sent.messages, operation.duration) |
sent, retried (back to Pending with backoff), dead (permanent error or last attempt) |
inbox (consumed.messages, process.duration) |
processed, retried, dead |
messagingkit.inbox.received.messages |
stored, duplicate (the id was already in the inbox) |
The four semantic-convention instruments also carry messaging.operation.name and messaging.operation.type (send for the outbox, process for the inbox), and failed attempts carry error.type:
- a transport's
Error.Codewhen it returns a failedResult— keep codes stable and low-cardinality, as ResultingKit asks; - the exception's full type name when one is thrown (the handler's own exception, not the reflection wrapper);
unknown_message_typeorno_handlerwhen the inbox cannot dispatch a row at all.
Message ids and destinations are never metric tags — ids are unbounded, and a destination is whatever a caller passed. Durations are measured with Stopwatch, not the injected TimeProvider, so they stay real when tests fake the clock. Histograms advise the OpenTelemetry messaging buckets (5 ms to 10 s).
Backlog. The two messages instruments report the last count the dispatcher or processor took on its own loop, at most once every BacklogSampleIntervalSeconds (default 30, 0 turns it off), and only while something is subscribed to them. Collection reads the cached count and never touches the database; nothing is reported until the first count. A failed count is logged as a warning and the previous one kept. Every replica counts the same shared table, so aggregate these across instances with max, not sum.
Compile-time checks
MessagingKit.Abstractions ships an analyzer, so referencing it is all it takes:
| Rule | Severity | Catches |
|---|---|---|
MK1001 |
Error | Two message types resolving to the same wire name — the receiver cannot tell them apart |
MK1002 |
Warning | A handler nothing registers, so it never runs |
MK1003 |
Error | [Message("")], which throws when the attribute is read at startup |
MK1004 |
Off by default | A message with no [Message], where renaming the type renames it on the wire |
MK1002 is a warning rather than an error because the registration may legitimately live in another project; suppress it there. Turn on MK1004 once messages are in production and a class rename would orphan queued rows:
dotnet_diagnostic.MK1004.severity = warning
Startup validation
A message delivered in-process with no handler registered fails the host at boot:
MessagingKit is misconfigured:
- 'send-email' is delivered in-process but no IMessageHandler<SendEmail> is registered.
Register a handler with Handles<SendEmail, THandler>(), or route it to another transport.
That is a failed deploy instead of a dead-lettered row discovered days later. Messages routed to another transport are skipped — they are handled wherever they land.
Configuration
Both halves bind the same shape, under Outbox and Inbox:
| Key | Default | Notes |
|---|---|---|
Enabled |
true |
false leaves the tables in place but runs no background job |
Schema / TableName |
messaging / outbox, inbox |
Must match what you passed to AddMessaging() / AddOutbox() / AddInbox() |
BatchSize / Concurrency |
50 / 4 |
Rows claimed per tick, and how many run in parallel |
PollIntervalSeconds |
5 |
Skipped while batches come back full |
LockDurationSeconds |
120 |
Must exceed the slowest realistic delivery |
MaxAttempts |
10 |
Then Dead |
BaseRetryDelaySeconds / MaxRetryDelaySeconds |
10 / 3600 |
Doubles per attempt, capped |
BacklogSampleIntervalSeconds |
30 |
How often, at most, the backlog instruments recount; 0 turns it off |
Operational notes
- Delivery is prompt, not instant. Committing a transaction that staged outbox rows wakes the dispatcher, and storing to the inbox wakes the processor, so a message does not wait out the poll interval. The interval is the fallback — a missed signal costs latency, never correctness.
- Ordering is not guaranteed. Messages dispatch in parallel; if you need per-key ordering, that is a transport concern.
- Delivery is at-least-once. A process can die after the transport accepts a message but before the row is marked
Sent. That is precisely why the receiver needs an inbox. LockDurationSecondsmust exceed your slowest send, or a second worker reclaims a row still in flight and the message goes out twice.- Both tables grow forever. Add a retention job that deletes
SentandProcessedrows past your audit window; keepDeaduntil someone has looked at them. - One inbox table per host, shared by every module and routed by message type. A module gets its own inbox when it gets its own database.
- Multiple replicas are safe —
SKIP LOCKEDguarantees a row is claimed by exactly one worker.
Testing against it
MessagingKit.Testing drives both halves to completion so a test asserts on the effect of a message rather than sleeping until the background loops happen to run:
outbox.Add(new SendEmail("ada@example.com", "Your invoice"));
await db.SaveChangesAsync();
await services.DrainMessagingAsync();
Assert.AreEqual(1, mailbox.Sent.Count);
It drives the same dispatcher and processor production uses, so what the test exercises is what ships. DrainOutboxAsync() and DrainInboxAsync() run one half when you want to assert on what was delivered before anything handles it.
Tests
dotnet test # every kit
dotnet test kits/messaging-kit/tests/MessagingKit.UnitTests # no Docker needed
dotnet test kits/messaging-kit/tests/MessagingKit.IntegrationTests # needs Docker
MessagingKit.UnitTests covers naming, registration, signalling, backlog sampling, startup validation, and the analyzer — no infrastructure, so it runs in about a second and parallelises.
MessagingKit.IntegrationTests runs against a real PostgreSQL container using TestingKit, with a fake in-memory transport — the framework is transport-agnostic, so the tests need no broker. It is [assembly: DoNotParallelize]: the suite shares one database and resets between tests.
Test doubles shared by both live in MessagingKit.TestSupport so they cannot drift apart.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. 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. |
-
net10.0
- MessagingKit.Abstractions (>= 0.3.0)
- Microsoft.EntityFrameworkCore.Relational (>= 10.0.13)
- Microsoft.Extensions.Configuration (>= 10.0.13)
- Microsoft.Extensions.Configuration.Abstractions (>= 10.0.13)
- Microsoft.Extensions.DependencyInjection (>= 10.0.13)
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Diagnostics (>= 10.0.13)
- Microsoft.Extensions.Hosting.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.13)
- Microsoft.Extensions.Options (>= 10.0.13)
- Microsoft.Extensions.Options.ConfigurationExtensions (>= 10.0.13)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on MessagingKit.Outbox:
| Package | Downloads |
|---|---|
|
MessagingKit.InProcess
In-process transport for MessagingKit: delivers outbox messages straight into the inbox, so modules in one host talk over the same seam they would use across a broker. |
|
|
MessagingKit
Outbox and inbox in one registration. Reference this to give a module durable send and receive with in-process delivery by default. |
|
|
MessagingKit.Testing
Test helpers for MessagingKit: drive the outbox and inbox to completion inside a test instead of waiting on background polling. |
GitHub repositories
This package is not used by any popular GitHub repositories.