Skip to content

Common Mistakes

The top 10 mistakes when using Pragmatic.Messaging — and how to fix them.

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.


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.


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].


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.


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 publishes
await _repo.SaveAsync(reservation);
await _messageBus.PublishAsync(new ReservationConfirmed(...));
// If app crashes between these two lines → event lost!

Right:

// Entity raises domain event — outbox ensures atomicity
reservation.RaiseDomainEvent(new ReservationConfirmed(...));
await _repo.SaveAsync(reservation);
// OutboxInterceptor persists event in same TX → guaranteed delivery

Why: 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.


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.