Transports
Four transports share one abstraction (IMessageTransport): pick one with Use{Transport}()
inside UseMessaging(...). Publish is fan-out (topic/exchange), Send is point-to-point
(queue). On every transport a failed handler can no longer lose the message silently —
that is a construction-level guarantee since the safety wave.
| | Channels | RabbitMQ | Kafka | Azure Service Bus | SQL (Postgres/SqlServer) |
|---|---|---|---|---|
| Scope | in-process | broker | broker (log) | broker (cloud/emulator) | database (no broker) |
| Topology auto-created | n/a | ✅ exchange/queue/binding | ✅ AdminClient (AutoCreateTopics) | ✅ management API (graceful pass-through) | ✅ DDL idempotente (AutoCreateSchema) |
| Failed handler | → IDeadLetterStore | nack → DLX (pragmatic.dlx default) | → DLQ topic ({topic}.dlq default) | abandon → redelivery → native DLQ | backoff → __TransportDeadLetters |
| Redelivery (broker) | ❌ | via DLX policy | on rebalance/restart | ✅ up to MaxDeliveryCount | ✅ lease scaduto / nack, fino a MaxDeliveryCount |
| Ordering | FIFO per channel | per queue | ✅ per partition key | per session/partition key | per-queue best effort (id crescente) |
| Scheduled messages | via Jobs | via Jobs | via Jobs | ✅ native (ScheduleMessageAsync) | ✅ native (VisibleAt, cancel restart-safe) |
| Local testing | none needed | Testcontainers | Testcontainers (or Redpanda) | official emulator via Testcontainers | Sqlite/Testcontainers |
RabbitMQ
Section titled “RabbitMQ”app.UseMessaging(m => m.UseRabbitMq(o =>{ o.ConnectionString = "amqp://guest:guest@localhost:5672"; o.QueueType = "quorum"; // replicated queues (production clusters); default "classic" // o.DeadLetterExchange = null; // opt OUT of the default DLX — failed messages get DROPPED}));- Topology: one durable topic exchange per boundary (
{boundary}.events), one durable queue per handler, declared idempotently at startup/first use. The compile-time picture is in the generated_Infra.Messaging.Topology.g.cs. - Dead-lettering is ON by default: queues are declared with
x-dead-letter-exchange: pragmatic.dlx; the DLX and a durablepragmatic.dlx.dlqare auto-provisioned. SettingDeadLetterExchange = nullreverts to discard-on-failure (loud warning at subscribe time). - Publisher confirms are ON by default (
PublisherConfirms): a publish completes only after the broker confirms it — the outbox cannot silently lose messages between DB and broker. - Queue arguments are immutable on RabbitMQ: changing
QueueType/DLX on existing queues requires deleting and re-declaring them.
app.UseMessaging(m => m.UseKafka(o =>{ o.BootstrapServers = "localhost:9092"; o.DefaultPartitions = 6; // for auto-created topics (default 3) // o.AutoCreateTopics = false; // pre-provisioned topics (IaC)}));- Topology: topics are created explicitly via AdminClient before first use (publish,
subscribe, dead-letter) — works where
auto.create.topics.enableis off. - Dead-letter topic is ON by default: a failed handler’s message is published to
{topic}.dlq(with failure headers) and its offset committed. Without it, the failed offset would be implicitly committed by the next successful message on the partition — silent loss. - Ordering: the message key decides the partition. Precedence:
[PartitionKey]property (SG-resolved,x-partition-keyheader) →CorrelationId→MessageId. Declare the key as a body property ([property:]on a positional record parameter is not seen by the generator):
public sealed record StockMoved(Guid ProductId, int Delta){ [PartitionKey] public Guid Key => ProductId; // per-product ordering}Azure Service Bus
Section titled “Azure Service Bus”app.UseMessaging(m => m.UseAzureServiceBus(o =>{ o.ConnectionString = "<namespace or emulator connection string>"; o.MaxDeliveryCount = 5; // then ASB dead-letters natively}));- Topology: queues/topics/subscriptions are created via the management API. Where
management is unavailable — the local emulator (entities come from its
Config.json) or an IaC-provisioned namespace with a restricted SAS — the transport warns once and works against the pre-provisioned entities. - Failed handlers are abandoned: the broker redelivers with a growing
DeliveryCount(mapped intoMessageContext.RetryCount) and dead-letters natively afterMaxDeliveryCount. Nothing to configure, nothing to lose. - Scheduled messages are broker-native:
UseAzureServiceBusregisters anIMessageSchedulerbacked byScheduleMessageAsync— no database polling. [PartitionKey]maps to the native messagePartitionKey(sessions/partitioned entities).- v1 limitation: point-to-point queues can be sent to but the subscription binder
consumes topics — consume queues with a raw
ServiceBusReceiverif needed.
Local emulator
Section titled “Local emulator”The official emulator runs in Docker (AMQP only, no management API, entities from Config.json). With Testcontainers:
var container = new ServiceBusBuilder() .WithAcceptLicenseAgreement(true) .WithResourceMapping(configJsonBytes, "/ServiceBus_Emulator/ConfigFiles/Config.json") .Build();See AzureServiceBusContainerFixture in the Messaging test suite for a complete example
(pre-provisioned topic + subscription + queue, AutoCreateEntities = false).
SQL (PostgreSQL / SQL Server)
Section titled “SQL (PostgreSQL / SQL Server)”UseSqlTransport() (package Pragmatic.Messaging.Sql) — durable queues on THREE tables
(__TransportMessages, __TransportSubscriptions, __TransportDeadLetters), no broker to
operate. The provider comes from the options:
msg.UseSqlTransport(o =>{ o.ConfigureDbContext = db => db.UseNpgsql(connectionString); // or UseSqlServer(...) o.AutoCreateSchema = true; // idempotent DDL under advisory lock (default) o.PollingInterval = TimeSpan.FromSeconds(1); // adaptive up to MaxPollingInterval o.LockDuration = TimeSpan.FromMinutes(5); // lease per competing consumers o.MaxDeliveryCount = 10; // then transactional move to __TransportDeadLetters o.UseNotifications = true; // PostgreSQL: pg_notify wakeups (28-80ms latency)});Semantics:
- Publish = fan-out at INSERT: subscriptions are DURABLE rows (upsert on subscribe, dispose does NOT remove — ASB semantics); publish resolves subscribers (cached, TTL 30s) and inserts one row per subscription in one transaction. Send = single row on the queue.
- Competing consumers via lease: 3-phase portable CAS claim (select ids → CAS update
with token → re-select);
DeliveryCountincrements AT CLAIM so a crash mid-handler consumes an attempt (no poison loop). Ack = DELETE guarded by token; nack = exponential backoff onVisibleAt(2^n, cap 30min). Expired leases redeliver via the claim predicate. - Latency: adaptive polling (1s → 10s when idle, drain on full batch). On PostgreSQL,
pg_notifyin the SAME transaction as the INSERT wakes consumers cross-connection at commit — polling stays on as safety net. SQL Server: polling only. - Scheduler nativo:
VisibleAt = scheduledAtwith aSchedulingTokenId;CancelAsyncis a DELETE by token — durable and restart-safe (unlike ASB without the handle store). - Co-locate the tables in your own DbContext with
SqlTransportDbContext.ApplyTransportConfigurations(modelBuilder)(Pragmatic.Migrations).
Operational notes: DELETE-heavy tables benefit from autovacuum tuning on PostgreSQL;
handler slower than LockDuration can cause duplicate delivery (no lease auto-renew — keep
handlers idempotent); ordering under concurrency is best-effort like every other transport.
Channels (in-process)
Section titled “Channels (in-process)”UseChannels() — bounded System.Threading.Channels with backpressure (Capacity,
FullMode, ConsumerCount). Failed handlers are persisted to the registered
IDeadLetterStore. The full transport path (serialize → route → consume in a fresh DI
scope) runs in-process: ideal for modular monoliths and for tests that need transport
semantics without a broker.