MassTransit Transactional Messaging with .NET Aspire, RabbitMQ, Kafka, PostgreSQL, MongoDB, Outbox, Inbox, Retry, Error Queues, and Dead-Letter Handling

Mehdi Hadeli
@mehdihadeli
On this page
Table of contents
Introduction
Reliable messaging usually fails in the least interesting line of code.
The domain write succeeds, but the process crashes before the event leaves the service. A broker redelivers a message, and the consumer applies the same change twice. An internal post-commit projection needs retries and durability, so the team starts building an outbox table, duplicate detection, retry policies, error queues, and background delivery workers before the actual business feature is even finished.
This sample shows a practical way to solve the same problems with MassTransit and .NET Aspire. The design stays close to the Wolverine version: two services, PostgreSQL for transactional state, MongoDB for a read model, and a broker selected through configuration. The service shape is familiar, but the messaging model is different. In the MassTransit sample, the key building blocks are the Entity Framework bus outbox, consumer outbox and inbox state, transport-specific RabbitMQ or Kafka registration, and explicit retry and error-transport configuration.
Sample code: masstransit-transactional-messaging-aspire
Objectives
By the end of this article, you should be able to:
- Model a two-service sample around transactional writes, integration events, and post-commit internal processing.
- Configure MassTransit with PostgreSQL-backed Entity Framework outbox state.
- Publish broker messages through the bus outbox inside the same EF Core transaction as business data.
- Use MassTransit consumer outbox and inbox state to make broker consumption idempotent and transactional.
- Process internal post-commit work for a MongoDB read model without publishing from an uncommitted transaction.
- Configure retry and redelivery behavior centrally, while overriding Kafka-specific behavior when needed.
- Understand how MassTransit error queues differ from broker-native dead-letter queues and topics.
- Keep transport-specific code thin enough to switch between RabbitMQ and Kafka without forking the application design.
Sample Overview
The sample contains two microservices.
Catalogs is the write-side service.
- It stores product aggregates in PostgreSQL.
- It publishes
MessageEnvelope<ProductCreatedV1>throughIEventBus. - It sends
ProjectProductReadModelthroughIInternalCommandBusafter commit so MongoDB read model updates only after PostgreSQL transaction succeeds.
Orders is the downstream consumer.
- It listens to same integration event from RabbitMQ queue or Kafka topic, based on configuration.
- It uses MassTransit consumer outbox and inbox state backed by PostgreSQL on the RabbitMQ path.
- It applies centralized retry and error-transport behavior, and the sample keeps Kafka-specific overrides explicit where transport features differ.
- It stores an imported product record in its own PostgreSQL database.
The reusable pieces follow the same high-level split as the Wolverine sample:
src/Services/Sharedcontains contracts, messaging constants, andMessageEnvelope<T>.src/BuildingBlocks/BuildingBlocks.Integration.MassTransit,src/BuildingBlocks/BuildingBlocks.Integration.MassTransit.RabbitMQ, andsrc/BuildingBlocks/BuildingBlocks.Integration.MassTransit.Kafkacontain integration options, transport registration, outbox configuration, and producer helpers.src/BuildingBlocks/BuildingBlocks.DurableLocalQueuecontains the custom durable internal-command processor that replaces Wolverine-style local queue durability.src/Aspire/ECommerce.AppHostcontains Aspire orchestration.
Architecture Overview
The AppHost provisions PostgreSQL, MongoDB, and a broker. Both APIs receive PostgreSQL, MongoDB where needed, and broker connection details through Aspire resource references.
There are three distinct messaging concerns in the sample:
- External broker publishing from
Catalogs. - Internal post-commit processing inside
Catalogs. - Transactional broker consumption in
Orders.
MassTransit covers those concerns with two related but different mechanisms:
- Bus outbox for publish and send operations started inside an EF Core transaction.
- Consumer outbox for message handling that must update database and emit more messages once per successful consume, with inbox state used for duplicate detection.
For internal processing, MassTransit does not have Wolverine's built-in durable local queue. The sample fills that gap with BuildingBlocks.DurableLocalQueue — a custom background worker that polls a DurableMessage table in the same PostgreSQL database. That preserves the same ordering rule: do not update the MongoDB read model before the PostgreSQL transaction commits. The durable queue also survives process restarts, unlike an in-memory mediator approach.
%%{init: {
'theme': 'base',
'themeVariables': {
'background': '#fafaf7',
'primaryColor': '#ffffff',
'primaryTextColor': '#0f172a',
'primaryBorderColor': '#cbd5e1',
'lineColor': '#475569',
'secondaryColor': '#f3f4f6',
'tertiaryColor': '#f3f4f6',
'fontFamily': 'Geist, ui-sans-serif, sans-serif',
'fontSize': '14px',
'edgeLabelBackground': '#fafaf7',
'mainBkg': '#fafaf7',
'nodePadding': '12px',
'subGraphBkg': '#f3f4f6',
'subGraphBorder': '#cbd5e1'
}
}}%%
flowchart TB
%% ═══════════════════════════════════════════
%% Catalogs — Write Side
%% ═══════════════════════════════════════════
subgraph Catalogs["<b>📦 Catalogs Service</b>"]
direction TB
Api["CreateProduct\nEndpoint"]
CatPg[("PostgreSQL\ncatalogsdb")]
CatMongo[("MongoDB\ncatalogs-mongo")]
Outbox["EF Bus Outbox"]
Dq["Durable Command\nProcessor"]
Api -->|"① save aggregate"| CatPg
Api -->|"② publish event"| Outbox
Outbox -.->|"same transaction"| CatPg
Outbox -->|"③ dispatch"| Broker
Api -->|"④ enqueue command"| Dq
Dq -.->|"poll"| CatPg
Dq -->|"⑤ upsert"| CatMongo
end
%% ═══════════════════════════════════════════
%% Orders — Consumer Side
%% ═══════════════════════════════════════════
subgraph Orders["<b>📥 Orders Service</b>"]
direction TB
Consumer["ProductCreated\nConsumer"]
OrdPg[("PostgreSQL\nordersdb")]
Inbox["Consumer Outbox\n+ Inbox State"]
Retry(["Retry &\nRedelivery"])
ErrorQ(["Error Transport\n_orders-products_error"])
Broker -->|"consume"| Consumer
Consumer -->|"wraps in Tx"| Inbox
Inbox -.->|"dedup / track"| OrdPg
Consumer -->|"upsert"| OrdPg
Consumer --x|"failure"| Retry
Retry -->|"exhausted"| ErrorQ
end
%% ═══════════════════════════════════════════
%% Aspire AppHost
%% ═══════════════════════════════════════════
subgraph Infra["<b>⚙️ .NET Aspire AppHost</b>"]
direction LR
AppHost{{"AppHost"}}
InfraPg[("PostgreSQL 17")]
InfraMongo[("MongoDB 8.0")]
InfraBroker{{"RabbitMQ\nor Kafka"}}
AppHost -- "provisions" --> InfraPg
AppHost -- "provisions" --> InfraMongo
AppHost -- "provisions" --> InfraBroker
end
Client["🌐 Client"] -->|"HTTP POST"| Api
Broker{{"📨 Message Broker\ncatalogs-products-created"}}
%% ═══════════════════════════════════════════
%% Semantic roles: stores, messaging, services, optional paths, and context
%% ═══════════════════════════════════════════
classDef pg fill:#f3f4f6,stroke:#475569,color:#0f172a,stroke-width:1px,rx:8,ry:8
classDef mongo fill:#ffffff,stroke:#475569,color:#0f172a,stroke-width:1px,rx:8,ry:8
classDef outbox fill:#e6f4fb,stroke:#0284c7,color:#0f172a,stroke-width:1.5px,rx:8,ry:8
classDef inbox fill:#ffffff,stroke:#0f172a,color:#0f172a,stroke-width:1px,rx:8,ry:8
classDef broker fill:#e6f4fb,stroke:#0284c7,color:#0f172a,stroke-width:1.5px,rx:8,ry:8
classDef api fill:#ffffff,stroke:#0f172a,color:#0f172a,stroke-width:1px,rx:8,ry:8
classDef error fill:#f3f4f6,stroke:#475569,color:#0f172a,stroke-width:1px,stroke-dasharray:4 3,rx:8,ry:8
classDef durable fill:#f3f4f6,stroke:#475569,color:#0f172a,stroke-width:1px,rx:8,ry:8
classDef retry fill:#f3f4f6,stroke:#94a3b8,color:#475569,stroke-width:1px,stroke-dasharray:4 3,rx:8,ry:8
classDef client fill:#eef2f7,stroke:#94a3b8,color:#0f172a,stroke-width:1px,rx:8,ry:8
classDef host fill:#f3f4f6,stroke:#94a3b8,color:#475569,stroke-width:1px,stroke-dasharray:4 3,rx:8,ry:8
class CatPg,OrdPg,InfraPg pg
class CatMongo,InfraMongo mongo
class Outbox outbox
class Inbox inbox
class Broker,InfraBroker,AppHost broker
class Api,Consumer api
class ErrorQ error
class Dq durable
class Retry retry
class Client client
class Infra hostMassTransit Feature Setup
The sample builds on seven MassTransit capabilities. Here is how each one maps to the problem.
1. Entity Framework transactional outbox with PostgreSQL
MassTransit stores outgoing messages and consumer delivery state in relational tables so business writes and message dispatch can share a transaction boundary.
At the EF Core level, the sample maps the MassTransit outbox directly inside the write-side DbContext:
using BuildingBlocks.DurableLocalQueue;
using ECommerce.Services.Catalogs.Products.Models;
using MassTransit;
using Microsoft.EntityFrameworkCore;
namespace ECommerce.Services.Catalogs.Shared.Data;
public class CatalogsDbContext(DbContextOptions<CatalogsDbContext> options) : DbContext(options)
{
public DbSet<Product> Products => Set<Product>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddTransactionalOutboxEntities();
modelBuilder.AddDurableLocalQueueEntities();
}
}Focus here:
AddTransactionalOutboxEntities()is the MassTransit part: it adds relational outbox and delivery-state tables.AddDurableLocalQueueEntities()is the sample-specific addition that preserves restart-safe internal processing for MongoDB projection.
Then register the shared MassTransit layer with the real sample options object. The sample does not wire AddEntityFrameworkOutbox(...) ad hoc at every service call site; it centralizes that in MassTransitServiceCollectionExtensions.cs:
public static class MassTransitServiceCollectionExtensions
{
public static IServiceCollection AddMassTransitMessaging<TDbContext>(
this IServiceCollection services,
MassTransitOptions options,
Action<IBusRegistrationConfigurator> configure,
Action<IMediatorRegistrationConfigurator>? configureMediator = null,
Action<IServiceCollection>? configureServices = null,
IHostEnvironment? environment = null
)
where TDbContext : DbContext
{
if (options.Bus.UsePostCommitMediator)
{
services.AddMediator(x =>
{
configureMediator?.Invoke(x);
});
}
if (environment?.IsEnvironment("Test") == true)
{
services.AddMassTransitTestHarness(cfg =>
{
if (options.Bus.UseBusOutbox)
{
cfg.AddEntityFrameworkOutbox<TDbContext>(o =>
{
o.UsePostgres();
o.UseBusOutbox();
});
}
configure(cfg);
});
}
else
{
services.AddMassTransit(x =>
{
if (options.Bus.UseBusOutbox)
{
x.AddEntityFrameworkOutbox<TDbContext>(o =>
{
o.UsePostgres();
o.UseBusOutbox();
});
}
configure(x);
});
}
if (options.Bus.UseEnvelopePublisher)
{
services.AddScoped<IMassTransitMessagePublisher, MassTransitEnvelopePublisher>();
}
else
{
services.AddScoped<IMassTransitMessagePublisher, MassTransitPublishEndpointPublisher>();
}Focus here:
- lines
18-27decide whether post-commit mediator support is even registered. - lines
31-39and44-52show the same outbox behavior applied consistently in both test and runtime registrations. - lines
54-64decide which publishing abstraction the application layer gets, so feature code does not branch on transport.
What this gives you:
- outgoing publish and send operations are written to PostgreSQL first
- messages are dispatched only after the transaction commits
- the application does not publish directly from an uncommitted write-side transaction
At the lower layer, the shared registration code calls AddEntityFrameworkOutbox<TDbContext>(o => { o.UsePostgres(); o.UseBusOutbox(); }) only when options.Bus.UseBusOutbox is enabled.
2. Bus outbox for transactional publishing in Catalogs
The MassTransit bus outbox is the publisher-side equivalent of a transactional outbox. In the sample, the HTTP endpoint saves the aggregate and calls IEventBus. The RabbitMQ path uses IPublishEndpoint through MassTransitEnvelopePublisher. The Kafka path swaps the publisher implementation through AddKafkaMessagePublisher<MessageEnvelope<ProductCreatedV1>>(). With the bus outbox enabled, MassTransit captures the publish operation in outbox tables instead of sending it immediately.
The actual sample-backed endpoint is CreateProductEndpoint.cs:
using BuildingBlocks.Integration.MassTransit.Abstractions;
using ECommerce.Services.Catalogs.Products.Models;
using ECommerce.Services.Catalogs.Shared.Data;
using ECommerce.Services.Shared.Contracts.IntegrationEvents;
using ECommerce.Services.Shared.Contracts.InternalCommands;
using ECommerce.Services.Shared.Contracts.MessageEnvelope;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
namespace ECommerce.Services.Catalogs.Products.Features.CreatingProduct.v1;
internal static class CreateProductEndpoint
{
internal static RouteHandlerBuilder MapCreateProductEndpoint(
this IEndpointRouteBuilder endpoints
)
{
return endpoints.MapPost("/products", Handle).WithName("CreateProduct");
}
private static async Task<CreatedAtRoute<CreateProductResponse>> Handle(
[FromBody] CreateProductRequest request,
CatalogsDbContext dbContext,
IEventBus eventBus,
IInternalCommandBus internalCommandBus,
CancellationToken cancellationToken
)
{
var product = Product.Create(request.Code, request.Name, request.Price);
dbContext.Products.Add(product);
var integrationEvent = MessageEnvelope.Create(
new ProductCreatedV1(
product.Id,
product.Code,
product.Name,
product.Price,
product.CreatedAtUtc
)
);
await eventBus.PublishAsync(integrationEvent, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await internalCommandBus.EnqueueAsync(
new ProjectProductReadModel(
product.Id,
product.Code,
product.Name,
product.Price,
product.CreatedAtUtc
),
cancellationToken
);
return TypedResults.CreatedAtRoute(
new CreateProductResponse(product.Id, product.Code, product.Name, product.Price),
"CreateProduct",
new { id = product.Id }
);
}
}Focus here:
- lines
15-19keep the endpoint shape minimal: one route, one handler. - lines
21-28create the aggregate and build the integration-event envelope inside the same HTTP flow. - lines
30-40are the transactional messaging core: publish throughIEventBus, commit withSaveChangesAsync, then enqueue internal work. - lines
42-50show that the internal MongoDB projection path is no longer mediatorSendAsync; the current sample uses durableEnqueueAsync.
The important part is not the syntax. The important part is the ordering guarantee:
SaveChangesAsynccommits both business data and outbox records.- MassTransit delivers the broker message after that commit succeeds.
- If the transaction fails, neither the row nor the outgoing event is published.
3. Consumer outbox and inbox state in Orders
For consumers, MassTransit uses the Entity Framework outbox differently. When the sample applies UseEntityFrameworkOutbox<OrdersDbContext>(context) on the endpoint, MassTransit wraps the consumer in a database transaction, tracks inbox state for duplicate detection, stores outgoing messages in outbox state, and commits only after the consumer succeeds.
RabbitMQ endpoint example:
cfg.ReceiveEndpointWithPolicies<OrdersDbContext, ProductCreatedConsumer>(
context,
options.Bus,
MessagingConstants.ProductCreatedQueue);This is the closest MassTransit equivalent to a transactional inbox plus outbox pipeline.
What it buys you:
- duplicate broker deliveries are tracked in inbox state
- the imported product write and any follow-up publish/send happen in one transaction scope
- retries do not duplicate outgoing messages after a successful commit
This is stronger and more precise than saying "exactly once" at the transport level. The broker still delivers at least once. MassTransit uses inbox state and transaction boundaries so your consumer side effects stay idempotent and transactional.
4. RabbitMQ transport
RabbitMQ is the simpler transport path. The latest sample uses shared helper methods that keep Catalogs and Orders thin while centralizing endpoint policy setup.
The shared RabbitMQ helper stays small, and the service configuration decides queue topology and DLQ wiring:
public static IServiceCollection AddMassTransitRabbitMq<TDbContext>(
this IServiceCollection services,
MassTransitOptions options,
MassTransitRabbitMqRegistrationOptions? registrationOptions = null,
IHostEnvironment? environment = null
)
where TDbContext : DbContext
{
registrationOptions ??= new MassTransitRabbitMqRegistrationOptions();
services.AddMassTransitMessaging<TDbContext>(
options,
x =>
{
registrationOptions.ConfigureBus?.Invoke(x);
x.UsingRabbitMq(
(context, cfg) =>
{
cfg.Host(
new Uri(
options.RabbitMqConnectionString
?? throw new InvalidOperationException(
"Missing connection string 'rabbitmq'."
)
)
);
registrationOptions.ConfigureTransport?.Invoke(context, cfg);
cfg.ConfigureEndpoints(context);
}
);
},
registrationOptions.ConfigureMediator,
serviceCollection =>
{
registrationOptions.ConfigureServices?.Invoke(serviceCollection);
},
environment
);
return services;
}
public static void ReceiveEndpointWithPolicies<TDbContext, TConsumer>(
this IRabbitMqBusFactoryConfigurator cfg,
IRegistrationContext context,
MassTransitBusOptions busOptions,
string queueName,
Action<IRabbitMqReceiveEndpointConfigurator>? configureEndpoint = null
)
where TDbContext : DbContext
where TConsumer : class, IConsumer
{
cfg.ReceiveEndpoint(
queueName,
endpoint =>
{
endpoint.ApplyEndpointPolicies<TDbContext>(context, busOptions);
endpoint.ConfigureConsumer<TConsumer>(context);
configureEndpoint?.Invoke(endpoint);
}
);
}Focus here:
- the helper owns transport bootstrapping and endpoint policy application.
- service modules stay responsible only for message-specific topology and consumer registration.
In the actual sample, the RabbitMQ producer sets the MessageEnvelope<ProductCreatedV1> entity name to catalogs-products-created. The consumer side uses an explicit receive endpoint with the same queue name. The endpoint also configures broker dead-letter arguments and binds orders-products-dlq explicitly. MassTransit error transport remains a separate concern from the broker-native DLQ path.
5. Kafka transport with Rider
MassTransit handles Kafka through the Rider API. Publishing uses ITopicProducer<T>, and consumption uses TopicEndpoint with a consumer group. The sample still uses UsingInMemory for the base bus while the Kafka rider handles broker traffic.
The Kafka path is explicit about rider setup and the in-memory base bus that hosts the rider:
public static IServiceCollection AddMassTransitKafka<TDbContext>(
this IServiceCollection services,
MassTransitOptions options,
MassTransitKafkaRegistrationOptions? registrationOptions = null,
IHostEnvironment? environment = null
)
where TDbContext : DbContext
{
registrationOptions ??= new MassTransitKafkaRegistrationOptions();
services.AddMassTransitMessaging<TDbContext>(
options,
x =>
{
registrationOptions.ConfigureBus?.Invoke(x);
x.AddRider(rider =>
{
registrationOptions.ConfigureRider?.Invoke(rider);
rider.UsingKafka(
(context, kafka) =>
{
kafka.Host(
options.KafkaConnectionString
?? throw new InvalidOperationException(
"Missing connection string 'kafka'."
)
);
registrationOptions.ConfigureTransport?.Invoke(context, kafka);
}
);
});
x.UsingInMemory((context, cfg) => cfg.ConfigureEndpoints(context));
},
registrationOptions.ConfigureMediator,
serviceCollection =>
{
registrationOptions.ConfigurePublisher?.Invoke(serviceCollection);
registrationOptions.ConfigureServices?.Invoke(serviceCollection);
},
environment
);
return services;
}
public static void TopicEndpointWithPolicies<TDbContext, TMessage, TConsumer>(
this IKafkaFactoryConfigurator kafka,
IRegistrationContext context,
MassTransitBusOptions busOptions,
string topicName,
string groupId,
Action<IKafkaTopicReceiveEndpointConfigurator<Ignore, TMessage>>? configure = null
)
where TDbContext : DbContext
where TMessage : class
where TConsumer : class, IConsumer<TMessage>
{
kafka.TopicEndpoint<TMessage>(
topicName,
groupId,
endpoint =>
{
endpoint.ApplyEndpointPolicies<TDbContext>(context, busOptions);
endpoint.ConfigureConsumer<TConsumer>(context);
configure?.Invoke(endpoint);
}
);
}Focus here:
x.UsingInMemory(...)does not mean the messaging workflow is in-memory only; it hosts the base bus while the rider handles Kafka traffic.TopicEndpointWithPolicies(...)keeps Kafka consumption aligned with the same retry and policy model as RabbitMQ, while still allowing Kafka-specific overrides.
Kafka changes the transport wiring, but not the transactional design on the publisher side. The write model still commits before the event is dispatched. On the consumer side, this sample keeps Kafka-specific behavior explicit by disabling consumer outbox and delayed redelivery in kafkaBusOptions, while still using retry and a dedicated dead-letter forwarding consumer.
6. Retry and delayed redelivery
MassTransit separates immediate retry from delayed redelivery.
UseMessageRetryhandles short transient failures in-memory while the message is being consumed.UseDelayedRedeliveryre-enqueues the message for a later attempt.- If the consumer still fails after those policies are exhausted, the message moves to the error transport.
Shared policy example from MassTransitServiceCollectionExtensions.cs:
public static void ApplyEndpointPolicies<TDbContext>(
this IReceiveEndpointConfigurator endpoint,
IRegistrationContext context,
MassTransitBusOptions busOptions
)
where TDbContext : DbContext
{
var immediateRetries = busOptions.Retry.MaximumAttempts - 1;
if (immediateRetries > 0)
{
endpoint.UseMessageRetry(r => r.Immediate(immediateRetries));
}
if (busOptions.Retry.UseDelayedRedelivery)
{
endpoint.UseDelayedRedelivery(r =>
r.Intervals(busOptions.Retry.DelayedRedeliveryIntervals)
);
}
if (busOptions.UseConsumerOutbox)
{
endpoint.UseEntityFrameworkOutbox<TDbContext>(context);
}
if (!busOptions.UseErrorTransport)
{
endpoint.DiscardFaultedMessages();
}
}Focus here:
- retry, delayed redelivery, consumer outbox, and error-transport behavior are applied in one reusable policy point.
DiscardFaultedMessages()makes the error-transport toggle explicit instead of implicit.
That gives you a clear operational flow:
- try a few immediate retries for short-lived failures
- schedule redelivery for longer transient problems
- move the message to the configured error transport if it still cannot be processed
The sample applies that shared policy to both transports, but the Kafka path overrides two settings intentionally:
UseConsumerOutbox = falseRetry.UseDelayedRedelivery = false
That keeps the Kafka sample aligned with the current rider behavior and avoids treating both transports as operationally identical.
7. Error queues versus broker-native dead-letter queues
This distinction matters because Wolverine and MassTransit present it differently.
MassTransit has a built-in error transport. In the sample, Orders enables it with UseErrorTransport = true. When a message faults and retry plus redelivery are exhausted, it moves to an error endpoint such as orders-products_error for a queue-based transport. That is MassTransit behavior, not the same thing as a RabbitMQ dead-letter exchange or a Kafka dead-letter topic.
For most applications, MassTransit error queues are enough because they are consistent across transports and easy to inspect. If you specifically need broker-native dead-letter topology for RabbitMQ or a Kafka dead-letter topic strategy, treat that as an extra transport concern layered on top of the MassTransit defaults.
The sample now shows both paths explicitly in configuration:
- MassTransit default: retries plus MassTransit error transport
- broker-native strategy: RabbitMQ DLX and DLQ wiring, plus Kafka dead-letter topic forwarding
Do not conflate those two.
RabbitMQ dead-letter support is configured on the receive queue with x-dead-letter-exchange and x-dead-letter-routing-key, then bound to orders-products-dlq.
Kafka dead-letter support is modeled differently. The sample listens for Fault<MessageEnvelope<ProductCreatedV1>> on the Kafka path and forwards the original envelope to orders-products-dead-letter. That keeps the broker-specific dead-letter story explicit instead of implying Kafka gives you the same queue semantics as RabbitMQ.
Operational comparison: _error versus DLQ and DLT
At runtime, these paths answer different operational questions.
orders-products_errortells you MassTransit gave up after retry and redelivery and moved the failed message to its error transport.orders-products-dlqtells you RabbitMQ dead-letter topology captured the message at the broker layer.orders-products-dead-lettertells you the Kafka flow forwarded a faulted message into an explicit dead-letter topic strategy.
Use the MassTransit _error endpoint when you want transport-agnostic failure handling and a consistent place to inspect failed consumes across transports. Use a broker-native DLQ or DLT when your operators already work in RabbitMQ or Kafka tooling and want the failed-message story to live in broker topology.
In this sample, that usually means:
- inspect
orders-products_errorfirst when validating MassTransit policy behavior - inspect
orders-products-dlqwhen validating RabbitMQ DLX and DLQ wiring - inspect
orders-products-dead-letterwhen validating Kafka dead-letter forwarding
That split keeps policy concerns and broker concerns visible instead of mixing them into one failure bucket.
AppHost Infrastructure
The AppHost setup stays very close to the Wolverine sample.
It should:
- Provision one PostgreSQL server and create
catalogsdbandordersdb. - Provision one MongoDB server and create the read-model database.
- Provision either RabbitMQ or Kafka based on
Messaging__Transport. - Pass the selected transport and connection details to both APIs.
The real AppHost keeps that switch narrow in AppHost.cs:
var builder = DistributedApplication.CreateBuilder(args);
var transport =
builder.Configuration["Messaging:Transport"]?.Trim().ToLowerInvariant() ?? "rabbitmq";
var postgres = builder.AddPostgres("postgres").WithImage(PostgresImage).WithImageTag(PostgresTag);
var catalogsDb = postgres.AddDatabase("catalogsdb");
var ordersDb = postgres.AddDatabase("ordersdb");
var mongo = builder.AddMongoDB("mongo").WithImage(MongoImage).WithImageTag(MongoTag);
var catalogsMongo = mongo.AddDatabase("catalogs-mongo");
var catalogsApi = builder
.AddProject<Projects.ECommerce_Services_Catalogs_Api>("catalogs-api")
.WithReference(catalogsDb)
.WithReference(catalogsMongo)
.WithEnvironment("Messaging__Transport", transport);
var ordersApi = builder
.AddProject<Projects.ECommerce_Services_Orders_Api>("orders-api")
.WithReference(ordersDb)
.WithEnvironment("Messaging__Transport", transport);
switch (transport)
{
case "rabbitmq":
var rabbitMq = builder
.AddRabbitMQ("rabbitmq")
.WithImage(RabbitMqImage)
.WithImageTag(RabbitMqTag);
catalogsApi.WithReference(rabbitMq);
ordersApi.WithReference(rabbitMq);
break;
case "kafka":
var kafka = builder.AddKafka("kafka").WithImage(KafkaImage).WithImageTag(KafkaTag);
catalogsApi.WithReference(kafka);
ordersApi.WithReference(kafka);
break;
}Focus here:
- the databases and APIs are provisioned once.
- only the broker resource and references change with transport selection.
That keeps orchestration transport-aware without forking the business design.
Shared Messaging Contracts
The shared contracts stay close to the earlier sample shape, but the latest sample adds richer envelope metadata.
The key integration event is still ProductCreatedV1, and the envelope stays useful. The real envelope lives in MessageEnvelope.cs:
public sealed record MessageEnvelope<TMessage>(
Guid MessageId,
Guid CorrelationId,
DateTime OccurredAtUtc,
TMessage Message
) : IMessageEnvelopeMetadata
where TMessage : IMessage
{
Type IMessageEnvelopeMetadata.MessageType => typeof(TMessage);
}
public static class MessageEnvelope
{
public static MessageEnvelope<TMessage> Create<TMessage>(
TMessage message,
Guid? correlationId = null,
Guid? messageId = null,
DateTime? occurredAtUtc = null
)
where TMessage : IMessage
{
return new MessageEnvelope<TMessage>(
messageId ?? Guid.NewGuid(),
correlationId ?? Guid.NewGuid(),
occurredAtUtc ?? DateTime.UtcNow,
message
);
}
}Factory method keeps call sites small:
var integrationEvent = MessageEnvelope.Create(
new ProductCreatedV1(product.Id, product.Code, product.Name, product.Price, product.CreatedAtUtc)
);That envelope gives the sample stable metadata for correlation, observability, and test fixtures without duplicating headers into every event contract.
Transport names stay centralized in MessagingConstants:
catalogs-products-createdqueue name for RabbitMQ receive endpoint and topic/entity name for publish topologycatalogs-products-createdtopic name for Kafkaorders-productsconsumer group name for Kafkaorders-products_errorMassTransit error queue name when queue-based transport faults exhaust policiesorders-products-dlqRabbitMQ dead-letter queue nameorders-products-dead-letterKafka dead-letter topic name
Configuring MassTransit in Catalogs
The write-side MassTransit configuration has three responsibilities:
- register the EF outbox with PostgreSQL
- choose RabbitMQ or Kafka
- expose a small publishing abstraction to the application layer
RabbitMQ-oriented setup in latest sample:
switch (transport)
{
case MessagingTransportType.RabbitMq:
builder.Services.AddMassTransitRabbitMq<CatalogsDbContext>(
options,
new MassTransitRabbitMqRegistrationOptions
{
ConfigureTransport = (_, cfg) =>
{
cfg.PublishToRabbitQueue<MessageEnvelope<ProductCreatedV1>>(
MessagingConstants.ProductCreatedQueue
);
},
},
builder.Environment
);
break;
}Kafka-oriented setup in latest sample:
case MessagingTransportType.Kafka:
builder.Services.AddMassTransitKafka<CatalogsDbContext>(
options,
new MassTransitKafkaRegistrationOptions
{
ConfigureRider = rider =>
{
MassTransit.KafkaProducerRegistrationExtensions.AddProducer<
MessageEnvelope<ProductCreatedV1>
>(rider, MessagingConstants.ProductCreatedTopic);
},
ConfigurePublisher = services =>
{
services.AddKafkaMessagePublisher<MessageEnvelope<ProductCreatedV1>>();
},
},
builder.Environment
);
break;The application code should not care which transport is active. It depends on IEventBus, backed by IPublishEndpoint for RabbitMQ or a Kafka-specific publisher implementation for ITopicProducer<T>.
Transactional Outbox in the Create Product Slice
The business flow in CreateProductEndpoint is still the center of the sample.
A sample-backed version looks like this:
private static async Task<CreatedAtRoute<CreateProductResponse>> Handle(
[FromBody] CreateProductRequest request,
CatalogsDbContext dbContext,
IEventBus eventBus,
IInternalCommandBus internalCommandBus,
CancellationToken cancellationToken
)
{
var product = Product.Create(request.Code, request.Name, request.Price);
dbContext.Products.Add(product);
var integrationEvent = MessageEnvelope.Create(
new ProductCreatedV1(
product.Id,
product.Code,
product.Name,
product.Price,
product.CreatedAtUtc
)
);
await eventBus.PublishAsync(integrationEvent, cancellationToken);
await dbContext.SaveChangesAsync(cancellationToken);
await internalCommandBus.EnqueueAsync(
new ProjectProductReadModel(
product.Id,
product.Code,
product.Name,
product.Price,
product.CreatedAtUtc
),
cancellationToken
);The critical rule is the same as before:
- do not publish directly to the broker outside a transaction-aware outbox
- do not trigger MongoDB projection before the PostgreSQL commit succeeds
The only real difference is which MassTransit-backed publisher implementation handles the outbound message.
Internal Post-Commit Processing for MongoDB Read Models
This is the one area where the MassTransit sample intentionally diverges from Wolverine.
MassTransit still does not provide Wolverine-style durable local queues as a built-in abstraction, but the current sample no longer uses mediator-only post-commit handling. It fills the gap with BuildingBlocks.DurableLocalQueue, which persists internal commands in PostgreSQL and processes them in the background after commit.
The write-side registration happens in ApplicationConfiguration.cs:
var options = new MassTransitOptions
{
DurableStorageConnectionString = connectionString,
RabbitMqConnectionString = builder.Configuration.GetConnectionString("rabbitmq"),
KafkaConnectionString = builder.Configuration.GetConnectionString("kafka"),
Bus = new MassTransitBusOptions { UseBusOutbox = true, UsePostCommitMediator = true },
};
switch (transport)
{
case MessagingTransportType.RabbitMq:
builder.Services.AddMassTransitRabbitMq<CatalogsDbContext>(
options,
new MassTransitRabbitMqRegistrationOptions
{
ConfigureTransport = (_, cfg) =>
{
cfg.PublishToRabbitQueue<MessageEnvelope<ProductCreatedV1>>(
MessagingConstants.ProductCreatedQueue
);
},
},
builder.Environment
);
break;
}
builder.Services.AddDurableLocalQueue<CatalogsDbContext>();
builder.Services.AddDurableCommandHandler<ProjectProductReadModel>(
async (command, sp, ct) =>
{
var repository = sp.GetRequiredService<IProductReadRepository>();
await repository.UpsertAsync(
new ProductReadModel(
command.ProductId,
command.Code,
command.Name,
command.Price,
command.CreatedAtUtc
),
ct
);
}
);Focus here:
- lines
27-36still configure MassTransit itself, including bus outbox and transport selection. - lines
64-82are the important divergence from the Wolverine article wording: the sample explicitly registers a durable local queue and a durable command handler, not only an in-memory mediator path.
The actual projection consumer stays small in ProjectProductReadModelConsumer.cs:
using ECommerce.Services.Catalogs.Shared.Contracts;
using ECommerce.Services.Catalogs.Shared.ReadModels;
using ECommerce.Services.Shared.Contracts.InternalCommands;
using MassTransit;
namespace ECommerce.Services.Catalogs.Products.Features.ProjectingProductReadModel.v1;
public sealed class ProjectProductReadModelConsumer(IProductReadRepository repository)
: IConsumer<ProjectProductReadModel>
{
public Task Consume(ConsumeContext<ProjectProductReadModel> context)
{
var message = context.Message;
return repository.UpsertAsync(
new ProductReadModel(
message.ProductId,
message.Code,
message.Name,
message.Price,
message.CreatedAtUtc
),
context.CancellationToken
);
}
}This is the same teaching point as in the Wolverine article: the handler logic stays small because durability and polling mechanics live below it.
Transactional Consumption in Orders
The consumer-side MassTransit configuration lives in Orders and focuses on one policy combination:
- retry and redelivery for transient failures
- Entity Framework consumer outbox for inbox state and transactional side effects
Consumer example from the real sample:
using ECommerce.Services.Orders.Products.Models;
using ECommerce.Services.Orders.Shared.Data;
using ECommerce.Services.Shared.Contracts.IntegrationEvents;
using ECommerce.Services.Shared.Contracts.MessageEnvelope;
using MassTransit;
using Microsoft.EntityFrameworkCore;
namespace ECommerce.Services.Orders.Products.Features.ConsumingProductCreated.v1;
public sealed class ProductCreatedConsumer(OrdersDbContext dbContext)
: IConsumer<MessageEnvelope<ProductCreatedV1>>
{
public async Task Consume(ConsumeContext<MessageEnvelope<ProductCreatedV1>> context)
{
var message = context.Message.Message;
if (
string.Equals(
message.Code,
"faulty-product-created",
StringComparison.OrdinalIgnoreCase
)
)
{
throw new InvalidOperationException(
"Intentional consumer failure for retry and dead-letter tests."
);
}
var existing = await dbContext.ImportedProducts.SingleOrDefaultAsync(
x => x.Id == message.ProductId,
context.CancellationToken
);
if (existing is null)
{
dbContext.ImportedProducts.Add(
ImportedProduct.Create(
message.ProductId,
message.Code,
message.Name,
message.Price,
message.CreatedAtUtc
)
);
}
else
{
existing.Update(message.Code, message.Name, message.Price, message.CreatedAtUtc);
}
await dbContext.SaveChangesAsync(context.CancellationToken);
}
}Focus here:
- lines
10-14show the actual consumer contract: consume the envelope, then unwrap the business payload. - lines
15-24show the built-in fault injection seam the tests use to verify retry and dead-letter behavior. - lines
26-40show the imported-product upsert path and the finalSaveChangesAsync, which is the side effect that the consumer outbox protects transactionally.
With UseEntityFrameworkOutbox<OrdersDbContext>(context) on the RabbitMQ endpoint, MassTransit handles the transaction and inbox state around the consumer. The consumer itself stays focused on business logic.
Validation
The current test suite proves the important parts of the sample, but it no longer tries to scrape every broker artifact end to end.
The strongest coverage is now in five places:
CreateProductTestsposts to/api/v1/catalogs/products, verifies the PostgreSQL write model, then polls MongoDB until the read model appears.CreateProductTestsalso waits for the PostgreSQLOutboxMessagetable to drain, proving the bus outbox delivered the integration event.ErrorQueueTestsresolves the realProductCreatedConsumerfrom DI, invokes it with a mockedConsumeContext, asserts the expected failure, and verifies no imported product row is persisted for the faulty message.KafkaErrorQueueTestsverifies the same failure semantics forProductCreatedConsumer, then testsKafkaDeadLetterForwardingConsumerdirectly by asserting it forwards the original envelope toITopicProducer<MessageEnvelope<ProductCreatedV1>>.- Unit tests cover
ProductCreatedConsumerandProjectProductReadModelConsumerso the write-side and read-model update logic stay small and focused.
The Catalogs integration test is the main end-to-end flow test:
[Fact]
public async Task PostProduct_ShouldCreateWriteAndReadModels_AndPublishEvent()
{
var cancellationToken = TestContext.Current.CancellationToken;
using var client = Factory.CreateClient();
var request = new
{
code = "catalog-101",
name = "Test Basket",
price = 15.25m,
};
var response = await client.PostAsJsonAsync(
"/api/v1/catalogs/products",
request,
cancellationToken
);
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
var created = await response.Content.ReadFromJsonAsync<CreateProductResult>(
cancellationToken
);
Assert.NotNull(created);
var published = await WaitForOutboxToDrainAsync(
TimeSpan.FromSeconds(30),
cancellationToken
);
Assert.True(
published,
"Expected the product-created event to be delivered by the bus outbox."
);
var mongoClient = new MongoClient(MongoConnectionString);
var database = mongoClient.GetDatabase("catalogs-mongo");
var collection = database.GetCollection<ProductReadModel>("product-read-models");
ProductReadModel? readModel = null;
for (var attempt = 0; attempt < 20; attempt++)
{
var readModels = await collection
.Find(Builders<ProductReadModel>.Filter.Empty)
.ToListAsync(cancellationToken);
readModel = readModels.SingleOrDefault(x => x.Id == created!.Id);
if (readModel is not null)
{
break;
}
await Task.Delay(250, cancellationToken);
}
}The Orders failure tests are now narrower and more deterministic. Instead of depending on broker inspection from the test host, they verify the consumer failure semantics and the Kafka forwarding logic directly.
That distinction still matters because the runtime configuration still supports different operational paths even though the automated tests now focus on application behavior:
- RabbitMQ: the sample configures MassTransit error transport plus RabbitMQ DLX and
orders-products-dlq - Kafka: the sample configures explicit dead-letter forwarding through
KafkaDeadLetterForwardingConsumer - Orders tests: the sample proves consumer failure semantics and non-persistence for faulty messages
- MongoDB projection: the sample proves the read model appears after the create flow completes
Transport Switching Strategy
The cleanest design is to isolate transport differences at the integration edge.
Shared application concerns:
- contracts
- envelope
- business endpoints
- consumer logic
- EF outbox configuration
- retry and outbox policies
Transport-specific concerns:
- RabbitMQ bus host and exchange or queue topology
- Kafka rider registration, producers, and topic endpoints
- any broker-native dead-letter customization
That means the switch can stay narrow:
Messaging:Transport=rabbitmqusesUsingRabbitMqMessaging:Transport=kafkausesAddRider(...UsingKafka...)
Everything above that layer stays the same.
What Changes Relative to Wolverine
This comparison is the part readers usually need most.
| Concern | Wolverine sample | MassTransit version |
|---|---|---|
| Transactional publish | durable outbox | EF bus outbox |
| Consumer idempotency | durable inbox | consumer outbox plus inbox state |
| Internal post-commit work | durable local queues | custom durable local queue in current sample; otherwise mediator or dedicated durable endpoint |
| Retry | retry policy on listeners | UseMessageRetry and UseDelayedRedelivery |
| Failed-message destination | error queue or native DLQ depending on transport | MassTransit error transport by default, optional RabbitMQ DLQ and Kafka dead-letter topic |
| Kafka integration | native transport API | Rider |
The most important difference is internal durable processing. Wolverine ships durable local processing as a first-class feature. In this MassTransit sample, that gap is filled explicitly with a custom durable local queue. Without that extra building block, you either accept a lighter post-commit in-process model or make the internal work a real durable messaging flow.
That is not a weakness in itself. It is simply a different design center.
Prerequisites
You need:
- .NET 10 SDK
- Docker Desktop or another supported OCI runtime
- A recent .NET Aspire workload and tooling setup
- MassTransit packages for chosen transport and EF Core outbox support
Typical package set:
<PackageReference Include="MassTransit" Version="8.*" />
<PackageReference Include="MassTransit.EntityFrameworkCore" Version="8.*" />
<PackageReference Include="MassTransit.RabbitMQ" Version="8.*" />
<PackageReference Include="MassTransit.Kafka" Version="8.*" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.*" />Conclusion
MassTransit can solve the same broad problem as the Wolverine sample: transactional publishing, idempotent consumption, retries, and operationally visible failure handling across RabbitMQ or Kafka.
The design is close, but not identical.
Use the EF bus outbox when publishing from the write side. Use the consumer outbox when a consumer updates PostgreSQL and may publish more messages. Treat retry and delayed redelivery as first-class policies. Treat MassTransit error queues as the default failed-message story, and add broker-native dead-letter topology only when you have a concrete operational reason.
For internal post-commit processing, be explicit. This sample uses a custom durable local queue to keep MongoDB projection restart-safe. If you do not build that extra layer, mediator can be enough for post-commit ordering, but it is not a substitute for durable local processing.
That clarity is what keeps the architecture honest.


