Common Mistakes
The top 10 mistakes when using Pragmatic.Messaging — and how to fix them.
1. Forgetting partial on the Handler
Section titled “1. Forgetting partial on the Handler”Wrong:
[MessageHandler]public sealed class OrderCreatedHandler : IMessageHandler<OrderCreated>{ public Task HandleAsync(OrderCreated message, MessageContext context, CancellationToken ct) { ... }}Right:
[MessageHandler]public sealed partial class OrderCreatedHandler : IMessageHandler<OrderCreated>{ public Task HandleAsync(OrderCreated message, MessageContext context, CancellationToken ct) { ... }}Why: The SG generates [LoggerMessage] partial methods on the _Pipeline class. Without partial, you get PRAG0801 warning and the pipeline can’t integrate logging stubs into your class.
2. Not Implementing IMessageHandler
Section titled “2. Not Implementing IMessageHandler”Wrong:
[MessageHandler]public sealed partial class InvoicePaidHandler // Missing interface!{ public Task HandleAsync(InvoicePaid message, MessageContext context, CancellationToken ct) { ... }}Right:
[MessageHandler]public sealed partial class InvoicePaidHandler : IMessageHandler<InvoicePaid>{ public Task HandleAsync(InvoicePaid message, MessageContext context, CancellationToken ct) { ... }}Why: The SG finds the message type T from the IMessageHandler<T> interface. Without it, the SG can’t determine what message type the handler processes → PRAG0800 error.
3. Retry with MaxAttempts = 0
Section titled “3. Retry with MaxAttempts = 0”Wrong:
[MessageHandler][Retry(MaxAttempts = 0)] // No retry at all — but why use the attribute?public sealed partial class PaymentHandler : IMessageHandler<ProcessPayment> { ... }Right:
[MessageHandler][Retry(MaxAttempts = 3, Strategy = BackoffStrategy.ExponentialWithJitter)]public sealed partial class PaymentHandler : IMessageHandler<ProcessPayment> { ... }Why: MaxAttempts = 0 means zero retries → the handler fails immediately on first error. This triggers PRAG0802 error. If you don’t want retry, simply don’t add [Retry].
4. Not Calling BatchTracker in Handlers
Section titled “4. Not Calling BatchTracker in Handlers”Wrong:
[MessageHandler]public sealed partial class PriceRecalcHandler( IBatchProgressStore store) : IMessageHandler<RecalcPriceBatch>{ public async Task HandleAsync(RecalcPriceBatch batch, MessageContext context, CancellationToken ct) { await RecalculatePrices(batch); // Forgot to report progress! Batch stays at 0% forever. }}Right:
[MessageHandler]public sealed partial class PriceRecalcHandler( IBatchProgressStore store) : IMessageHandler<RecalcPriceBatch>{ public async Task HandleAsync(RecalcPriceBatch batch, MessageContext context, CancellationToken ct) { try { await RecalculatePrices(batch); await BatchTracker.ReportSuccessAsync(context, store, ct); } catch { await BatchTracker.ReportFailureAsync(context, store, ct); throw; } }}Why: The BatchDispatcher sets batch headers on MessageContext, but the handler must explicitly report progress. Without BatchTracker.ReportSuccessAsync(), the BatchProgress.Completed counter never increments.
5. Using [EnableOutbox] on a Non-DbContext Class
Section titled “5. Using [EnableOutbox] on a Non-DbContext Class”Wrong:
[EnableOutbox]public class BookingRepository { ... } // Not a DbContext!Right:
[EnableOutbox]public class BookingDbContext : PragmaticDbContext { ... }Why: The outbox interceptor hooks into EF Core’s SaveChangesInterceptor. It only works on DbContext subclasses. PRAG0830 error.
6. Saga Without [SagaStart]
Section titled “6. Saga Without [SagaStart]”Wrong:
[Saga<OrderState>]public partial class OrderSaga : ISaga<OrderState>{ [InState(OrderState.Created, NextState = OrderState.Paid)] public ProcessPaymentAction Handle(PaymentReceived e) { ... }}Right:
[Saga<OrderState>]public partial class OrderSaga : ISaga<OrderState>{ [SagaStart] public ValidateOrderAction Handle(OrderCreated e) { ... }
[InState(OrderState.Created, NextState = OrderState.Paid)] public ProcessPaymentAction Handle(PaymentReceived e) { ... }}Why: Every saga needs exactly one entry point. The SG-generated orchestrator looks for [SagaStart] to know which event creates a new saga instance. Without it → PRAG0814 error.
7. Catching OperationCanceledException in Handlers
Section titled “7. Catching OperationCanceledException in Handlers”Wrong:
public async Task HandleAsync(OrderCreated message, MessageContext context, CancellationToken ct){ try { await ProcessOrder(message, ct); } catch (Exception ex) // Catches OperationCanceledException too! { _logger.LogError(ex, "Failed"); }}Right:
public async Task HandleAsync(OrderCreated message, MessageContext context, CancellationToken ct){ try { await ProcessOrder(message, ct); } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogError(ex, "Failed"); }}Why: The generated retry loop uses when (ex is not OperationCanceledException) to avoid retrying cancellations. If your handler swallows OperationCanceledException, the [Timeout] attribute can’t cancel the handler, and shutdown becomes slow.
8. Publishing Messages Without EnableOutbox in Cross-Boundary Scenarios
Section titled “8. Publishing Messages Without EnableOutbox in Cross-Boundary Scenarios”Wrong:
// Booking module saves entity, then manually publishesawait _repo.SaveAsync(reservation);await _messageBus.PublishAsync(new ReservationConfirmed(...));// If app crashes between these two lines → event lost!Right:
// Entity raises domain event — outbox ensures atomicityreservation.RaiseDomainEvent(new ReservationConfirmed(...));await _repo.SaveAsync(reservation);// OutboxInterceptor persists event in same TX → guaranteed deliveryWhy: Without the outbox, there’s a window between SaveAsync and PublishAsync where a crash loses the event. The outbox interceptor persists the event atomically with the entity changes.
9. Using InMemory Stores in Production
Section titled “9. Using InMemory Stores in Production”Wrong:
app.UseMessaging(msg =>{ msg.UseRabbitMq(rmq => { rmq.ConnectionString = "..."; }); msg.EnableIdempotency(); // InMemoryIdempotencyStore — lost on restart! msg.EnableSagas(); // InMemorySagaRepository — lost on restart!});Right:
app.UseMessaging(msg =>{ msg.UseRabbitMq(rmq => { rmq.ConnectionString = "..."; }); msg.EnableIdempotency(); msg.EnableSagaPersistence(); // EF Core-backed msg.EnableBatchPersistence(); // EF Core-backed // Implement IIdempotencyStore backed by Redis/DB for production dedup});Why: InMemoryIdempotencyStore, InMemorySagaRepository, and InMemoryBatchProgressStore lose all state on process restart. In production with RabbitMQ, messages may be redelivered after restart → duplicates without persistent idempotency.
10. Accessing DbContext Directly in Cross-Boundary Handlers
Section titled “10. Accessing DbContext Directly in Cross-Boundary Handlers”Wrong:
[MessageHandler]public sealed partial class ReservationConfirmedHandler( BookingDbContext dbContext) : IMessageHandler<ReservationConfirmed>{ public async Task HandleAsync(ReservationConfirmed @event, MessageContext context, CancellationToken ct) { // Direct DB access to another boundary! var reservation = await dbContext.Reservations.FindAsync(@event.ReservationId); // Creates tight coupling, breaks boundary isolation }}Right:
[MessageHandler]public sealed partial class ReservationConfirmedHandler( IBillingActions billing) : IMessageHandler<ReservationConfirmed>{ public async Task HandleAsync(ReservationConfirmed @event, MessageContext context, CancellationToken ct) { // Use boundary interface — works in monolith AND distributed await billing.CreateDraftInvoice(@event.ReservationId, @event.GuestId, ...); }}Why: The event should contain all data the handler needs. If you access another boundary’s DbContext directly, you break the boundary isolation — the code won’t work when you switch to distributed deployment with BoundaryMode.Remote.