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*contains integration options, transport registration, outbox configuration, and producer helpers.src/BuildingBlocks/BuildingBlocks.Persistence.*contains PostgreSQL and MongoDB setup.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.
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"}}
%% ═══════════════════════════════════════════
%% Color Palette
%% ═══════════════════════════════════════════
classDef pg fill:#1e3a5f,stroke:#5b9bd5,color:#d6e8fa,stroke-width:2px
classDef mongo fill:#1d4d2e,stroke:#4caf50,color:#c8e6c9,stroke-width:2px
classDef outbox fill:#5a3e0a,stroke:#f5a623,color:#fce4b3,stroke-width:2px
classDef inbox fill:#0b4f4f,stroke:#2dd4bf,color:#ccfbf1,stroke-width:2px
classDef broker fill:#2d1b69,stroke:#a78bfa,color:#e0d9fc,stroke-width:2px
classDef api fill:#1f2a3a,stroke:#6b8db5,color:#dce6f2,stroke-width:2px
classDef error fill:#581c1c,stroke:#f87171,color:#fecaca,stroke-width:2px
classDef durable fill:#5c3b0a,stroke:#fb923c,color:#fed7aa,stroke-width:2px
classDef retry fill:#1c3a4a,stroke:#60a5fa,color:#bfdbfe,stroke-width:2px
classDef client fill:#374151,stroke:#9ca3af,color:#f3f4f6,stroke-width:2px
classDef host fill:#1b1b3a,stroke:#818cf8,color:#c7d2fe,stroke-width:2px
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 host
MassTransit 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, add MassTransit transactional outbox entities to your DbContext model:
using MassTransit;
using Microsoft.EntityFrameworkCore;
public class CatalogsDbContext : DbContext
{
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
modelBuilder.AddTransactionalOutboxEntities();
}
}
Then register the Entity Framework outbox and enable the bus outbox. The latest sample wraps that in a shared registration layer and drives it with MassTransitOptions:
var options = new MassTransitOptions
{
DurableStorageConnectionString = connectionString,
RabbitMqConnectionString = builder.Configuration.GetConnectionString("rabbitmq"),
KafkaConnectionString = builder.Configuration.GetConnectionString("kafka"),
Bus = new MassTransitBusOptions
{
UseBusOutbox = true,
UsePostCommitMediator = true,
},
};
builder.Services.AddMassTransitRabbitMq<CatalogsDbContext>(
options,
new MassTransitRabbitMqRegistrationOptions
{
ConfigureBus = x => x.AddConsumer<ProjectProductReadModelConsumer>(),
ConfigureMediator = x => x.AddConsumer<ProjectProductReadModelConsumer>(),
},
builder.Environment);
Sample paths: src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/Extensions/MassTransitServiceCollectionExtensions.cs, src/BuildingBlocks/BuildingBlocks.Integration.MassTransit.RabbitMQ/MassTransitRabbitMqExtensions.cs
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.
Minimal flow:
app.MapPost("/api/v1/products", async (
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.SendAsync(
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 });
});
Sample path: src/Services/Catalogs/ECommerce.Services.Catalogs/Products/Features/CreatingProduct/v1/CreateProductEndpoint.cs
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.
Minimal setup:
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<ProductCreatedConsumer, ProductCreatedConsumerDefinition>();
x.UsingRabbitMq((context, cfg) =>
{
cfg.Host(builder.Configuration.GetConnectionString("rabbitmq"));
cfg.Message<MessageEnvelope<ProductCreatedV1>>(m =>
{
m.SetEntityName("catalogs-products-created");
});
cfg.ReceiveEndpoint("catalogs-products-created", endpoint =>
{
endpoint.ApplyEndpointPolicies<OrdersDbContext>(context, options.Bus);
endpoint.ConfigureConsumer<ProductCreatedConsumer>(context);
});
});
});
Sample paths: src/BuildingBlocks/BuildingBlocks.Integration.MassTransit.RabbitMQ/MassTransitRabbitMqExtensions.cs, src/Services/Orders/ECommerce.Services.Orders/ApplicationConfiguration.cs
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.
Minimal setup:
builder.Services.AddMassTransit(x =>
{
x.AddConsumer<ProductCreatedConsumer, ProductCreatedConsumerDefinition>();
x.UsingInMemory((_, _) => { });
x.AddRider(rider =>
{
rider.AddConsumer<ProductCreatedConsumer>();
rider.AddProducer<MessageEnvelope<ProductCreatedV1>>("catalogs-products-created");
rider.UsingKafka((context, kafka) =>
{
kafka.Host(builder.Configuration["ConnectionStrings:kafka"]);
kafka.TopicEndpoint<MessageEnvelope<ProductCreatedV1>>(
"catalogs-products-created",
"orders-products",
endpoint =>
{
endpoint.ApplyEndpointPolicies<OrdersDbContext>(context, kafkaBusOptions);
endpoint.ConfigureConsumer<ProductCreatedConsumer>(context);
});
});
});
});
And publishing to Kafka:
public static class MassTransitKafkaPublisherRegistrationExtensions
{
public static IServiceCollection AddKafkaMessagePublisher<TMessage>(
this IServiceCollection services)
where TMessage : class
{
services.AddScoped<IMassTransitMessagePublisher, KafkaTopicMessagePublisher<TMessage>>();
return services;
}
}
internal sealed class KafkaTopicMessagePublisher<TMessage>(ITopicProducer<TMessage> producer)
: IMassTransitMessagePublisher
where TMessage : class
{
public Task PublishAsync<TRequestedMessage>(
TRequestedMessage message,
CancellationToken cancellationToken)
where TRequestedMessage : class
{
if (message is not TMessage typedMessage)
{
throw new InvalidOperationException(
$"Kafka publisher does not support message type '{typeof(TRequestedMessage).Name}'. Expected '{typeof(TMessage).Name}'.");
}
return producer.Produce(typedMessage, cancellationToken);
}
}
Sample paths: src/BuildingBlocks/BuildingBlocks.Integration.MassTransit.Kafka/MassTransitKafkaExtensions.cs, src/Services/Orders/ECommerce.Services.Orders/ApplicationConfiguration.cs, and src/Services/Catalogs/ECommerce.Services.Catalogs/ApplicationConfiguration.cs
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:
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);
}
}
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 transport switch stays small:
var transport =
builder.Configuration["Messaging:Transport"]?.Trim().ToLowerInvariant() ?? "rabbitmq";
switch (transport)
{
case "rabbitmq":
// add rabbitmq resource
break;
case "kafka":
// add kafka resource
break;
}
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:
public sealed record MessageEnvelope<TMessage>(
Guid MessageId,
Guid CorrelationId,
DateTime OccurredAtUtc,
TMessage Message)
where TMessage : IMessage;
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:
builder.Services.AddMassTransitRabbitMq<CatalogsDbContext>(
options,
new MassTransitRabbitMqRegistrationOptions
{
ConfigureBus = x => x.AddConsumer<ProjectProductReadModelConsumer>(),
ConfigureMediator = x => x.AddConsumer<ProjectProductReadModelConsumer>(),
ConfigureTransport = (_, cfg) =>
{
cfg.PublishToRabbitQueue<MessageEnvelope<ProductCreatedV1>>(
MessagingConstants.ProductCreatedQueue);
},
},
builder.Environment);
Kafka-oriented setup in latest sample:
builder.Services.AddMassTransitKafka<CatalogsDbContext>(
options,
new MassTransitKafkaRegistrationOptions
{
ConfigureBus = x => x.AddConsumer<ProjectProductReadModelConsumer>(),
ConfigureMediator = x => x.AddConsumer<ProjectProductReadModelConsumer>(),
ConfigureRider = rider =>
{
rider.AddProducer<MessageEnvelope<ProductCreatedV1>>(
MessagingConstants.ProductCreatedTopic);
},
ConfigurePublisher = services =>
{
services.AddKafkaMessagePublisher<MessageEnvelope<ProductCreatedV1>>();
},
},
builder.Environment);
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:
app.MapPost("/api/v1/products", async (
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.SendAsync(
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 });
});
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 MassTransit is not a one-for-one replacement for Wolverine.
Wolverine has durable local queues built in. MassTransit does not provide the same local durable queue abstraction as a first-class equivalent. Because of that, you need to choose one of three designs:
- Use a post-commit in-process mediator for simple internal commands.
- Use a dedicated durable queue or topic and consume it through MassTransit like any other message flow.
- Use a background projector that reacts to the same integration event and updates MongoDB asynchronously.
The latest sample chooses option 1 and enables it explicitly through MassTransitBusOptions.UsePostCommitMediator = true. That gives you post-commit ordering, but not restart-safe durability.
Minimal mediator setup:
if (options.Bus.UsePostCommitMediator)
{
services.AddMediator(x =>
{
configureMediator?.Invoke(x);
});
}
Trigger after the write transaction succeeds:
await dbContext.SaveChangesAsync(cancellationToken);
await internalCommandBus.SendAsync(
new ProjectProductReadModel(product.Id, product.Code, product.Name, product.Price, product.CreatedAtUtc),
cancellationToken);
And the consumer stays small:
public sealed class ProjectProductReadModelConsumer : IConsumer<ProjectProductReadModel>
{
private readonly IProductReadRepository _repository;
public ProjectProductReadModelConsumer(IProductReadRepository repository)
{
_repository = repository;
}
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);
}
}
Sample paths: src/Services/Catalogs/ECommerce.Services.Catalogs/ApplicationConfiguration.cs, src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/Extensions/MassTransitServiceCollectionExtensions.cs, src/BuildingBlocks/BuildingBlocks.Integration.MassTransit/MassTransitInternalCommandBus.cs, and src/Services/Catalogs/ECommerce.Services.Catalogs/Products/Features/ProjectingProductReadModel/v1/ProjectProductReadModelConsumer.cs
If you need restart-safe durability for that internal work, prefer option 2 instead: route the command through a real MassTransit receive endpoint backed by RabbitMQ or Kafka, or persist your own internal work table and process it separately.
That tradeoff should be explicit in the article, because otherwise readers will assume MassTransit has the same local durability feature as Wolverine.
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:
public class ProductCreatedConsumer : IConsumer<MessageEnvelope<ProductCreatedV1>>
{
private readonly OrdersDbContext _dbContext;
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 imported = await _dbContext.ImportedProducts.FindAsync(
new object[] { message.ProductId },
context.CancellationToken);
if (imported is null)
{
imported = new ImportedProduct(message.ProductId);
_dbContext.ImportedProducts.Add(imported);
}
imported.Name = message.Name;
imported.Price = message.Price;
}
}
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 published = await WaitForOutboxToDrainAsync(
TimeSpan.FromSeconds(30),
cancellationToken);
Assert.True(published);
}
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 | mediator, dedicated durable endpoint, or background projector |
| 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. If you need restart-safe local processing inside the same service without an external broker, Wolverine has the more direct feature. With MassTransit, 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. If you only need post-commit ordering, mediator can be enough. If you need durability across restarts, push that work through a real durable endpoint instead of assuming a Wolverine-style local queue exists.
That clarity is what keeps the architecture honest.


