.NET RabbitMQ — MassTransit, Topology, Evolution · Additional notes
12 min read- Additional notes
- Topology — what MassTransit declares automatically
- Performance characteristics
- Connection and prefetch tuning
- Observability
- Evolution and "migrations" — how this is actually done
- Topology evolution
- Definitions snapshot pattern (IaC for topology)
- Message contract versioning
- Blue-green queue migration (for arguments changes)
- Common pitfalls
- Comparison to Go (`amqp091-go` / `watermill`)
- MQTT vs RabbitMQ vs Kafka
- Interview talking points
- Further reading
Additional notes
Topology — what MassTransit declares automatically
For each registered consumer, MassTransit declares:
- An exchange named after the message type's full CLR name (e.g.,
Contracts:OrderPlaced). - A queue per consumer endpoint (default: kebab-case of consumer class).
- A binding from exchange → queue.
- An error queue (
<queue>_error) for poison messages. - A skipped queue (
<queue>_skipped) for messages with no handler.
All idempotent — re-running on a current broker is a no-op.
Performance characteristics
| Operation | Typical throughput / latency |
|---|---|
| Publish (small message) | ~5–10k msg/s per producer connection |
| Consume (single consumer instance, fast handler) | ~5–10k msg/s |
| End-to-end publish → consume (local broker) | ~2–5 ms |
| Saga state transition (with EF Core persistence) | ~10–50 ms |
| Outbox flush latency | QueryDelay (default 1 s) |
Bottlenecks: serialization (MassTransit uses System.Text.Json by default), saga persistence, and DB transactions when using outbox. For throughput-critical paths, consider:
- Batch consumers (
IConsumer<Batch<T>>) - Persistent vs transient delivery mode trade-off
- Prefetch count tuning (
cfg.PrefetchCount = 16)
Connection and prefetch tuning
cfg.Host("rabbitmq", h =>
{
h.RequestedConnectionTimeout(10_000);
h.Heartbeat(60);
});
cfg.PrefetchCount = 16; // server-side prefetch
cfg.ConcurrentMessageLimit = 8; // client-side concurrency cap
cfg.ReceiveEndpoint("orders", e =>
{
e.PrefetchCount = 32; // per-endpoint override
e.ConcurrentMessageLimit = 16;
});
Defaults are conservative (PrefetchCount = 8). For high-throughput consumers with fast handlers, bump to 32–128. For slow handlers (external API calls), keep low so messages don't sit in client buffer.
Observability
MassTransit ships first-class OpenTelemetry support:
builder.Services.AddOpenTelemetry().WithTracing(t => t
.AddSource("MassTransit")
.AddAspNetCoreInstrumentation()
.AddOtlpExporter());
Spans are emitted for publish, consume, and saga transitions. Distributed trace context propagates via message headers automatically.
For Prometheus metrics:
.WithMetrics(m => m.AddMeter("MassTransit").AddPrometheusExporter())
Evolution and "migrations" — how this is actually done
Two parallel evolution stories: topology (queues, exchanges, bindings, arguments) and message format (contract shape).
Topology evolution
MassTransit re-declares topology on every startup with cfg.ConfigureEndpoints(context). This is the messaging analog of MigrateAsync.
What's safe to change in-place:
- Adding new consumers → new queues/bindings declared, no impact on existing
- Adding new message types → new exchanges declared
- Removing consumers → orphan queues remain but receive nothing (cleanup is manual)
What requires care:
- Changing queue arguments (TTL, max-length, dead-letter-exchange, durability) is NOT allowed in-place by RabbitMQ. Mismatched arguments throw
406 PRECONDITION_FAILEDon declare. You have two options:
- Delete the queue manually, let MassTransit redeclare on next startup
- Use a versioned queue name (
orders-v2), deploy with both for a transition period, drain the old, retire it
- Renaming a consumer class changes the derived queue name → looks like a brand-new queue, old queue gets orphaned. Workaround:
e.SetQueueName("explicit-name")from the start.
Definitions snapshot pattern (IaC for topology)
For full reproducibility, export topology as JSON and apply via rabbitmqctl:
# Snapshot current state
rabbitmqctl export_definitions definitions.json
# Apply on a fresh broker
rabbitmqctl import_definitions definitions.json
Useful for spinning up fresh brokers (test environments, disaster recovery), aligning staging ↔ prod, and reviewing topology changes in PR diffs. Some teams treat definitions.json as IaC — checked into git, applied during broker deploy.
Message contract versioning
When the shape of a message changes, you have three options:
1. Backwards-compatible additive change — add optional fields with defaults:
// Before
public record OrderPlaced(Guid OrderId, decimal Amount);
// After (still compatible)
public record OrderPlaced(Guid OrderId, decimal Amount, string Currency = "USD");
Old producers and new consumers coexist. Old consumers ignore the new field. Most evolutions should look like this.
2. Versioned message type (breaking change) — new type, new exchange, both run in parallel:
namespace Contracts.V1 { public record OrderPlaced(Guid OrderId, decimal Amount); }
namespace Contracts.V2 { public record OrderPlaced(Guid OrderId, decimal AmountMinor, string Currency); }
Producer publishes both during transition. Consumers migrate independently. Retire V1 once no producers publish it.
3. Schema registry (heavyweight) — use Confluent Schema Registry or Apicurio. Overkill for most .NET shops but standard in Kafka-heavy environments.
Blue-green queue migration (for arguments changes)
When you must change queue arguments:
1. Deploy new consumer reading from queue `orders-v2` (with new arguments).
2. Deploy producer publishing to BOTH old and new exchanges (transition mode).
3. Wait for old queue `orders` to drain.
4. Decommission old consumer and queue.
5. Remove producer dual-publish.
Zero-downtime, slightly more deploy ceremony.
How easily? Topology evolution in MassTransit is dramatically easier than custom AMQP code because the framework handles idempotent declaration. The hard cases (argument changes, renames) require ceremony but are well-documented. The discipline cost: pick explicit queue names from day one (e.SetQueueName("orders")) rather than relying on class-name derivation, so consumer refactors don't orphan queues.
Common pitfalls
406 PRECONDITION_FAILEDon startup — queue exists with different arguments. Solution: delete-and-redeclare, or version the queue name.- Forgetting
AddEntityFrameworkOutbox—Publishinside a transaction sends immediately, not on commit. Dual-write inconsistency follows. - High prefetch + slow handlers — messages sit in the client buffer, redelivery on crash multiplies effective load. Tune prefetch to handler speed.
- Saga state not persisted — uses InMemory by default. Restart = lost state. Configure EF Core / Redis / Marten persistence explicitly.
- Forgetting to register consumers —
AddConsumeris mandatory; otherwise the queue is declared but no one reads it. - No DLX (dead-letter exchange) — failed messages pile up in
_errorindefinitely. Either drain manually or add a DLX with TTL for auto-cleanup. - Mixing
PublishandSendsemantics —Publish= fan-out via exchange,Send= point-to-point to a specific queue. Confusing the two leads to "the message disappeared" mysteries. - Long-running consumers without
CancellationToken— graceful shutdown waits forever for an in-flight message.
Comparison to Go (amqp091-go / watermill)
| Aspect | .NET (MassTransit) | Go (amqp091-go or watermill) |
|---|---|---|
| Topology declaration | Automatic via ConfigureEndpoints | Manual (amqp091) or via watermill plugin |
| Type-safe contracts | Yes (full CLR types) | Manual marshal/unmarshal |
| Consumer pattern | IConsumer<T> class | function or watermill.Handler |
| Sagas | First-class (state machine) | Manual (or watermill plugin, limited) |
| Outbox | First-class (AddEntityFrameworkOutbox) | Manual or watermill-sql |
| Retry / redelivery | Declarative pipeline | Manual (or watermill-rabbitmq middleware) |
| Scheduled messages | First-class (SchedulePublish) | Manual (delayed exchange + TTL) |
| Distributed tracing | Auto via OTel | Manual hook |
| Boilerplate | Low | High (amqp091) / Medium (watermill) |
| Footprint | ~50 MB extra for MassTransit | ~5 MB for amqp091 |
| Maturity | Production-proven in many EU SaaS shops | Direct AMQP mature; watermill smaller community |
MQTT vs RabbitMQ vs Kafka
The three get lumped together as "message brokers", but they sit on three different models, and the interview question is really which model fits the workload.
| Aspect | MQTT (Mosquitto, EMQX, HiveMQ) | RabbitMQ (AMQP 0-9-1) | Kafka |
|---|---|---|---|
| Model | Lightweight pub/sub broker: clients publish to hierarchical topics (plant/3/line/7/temp), subscribers match with + / # wildcards | Queue broker: publish to an exchange, routed by bindings into queues; a message is removed once acked | Distributed append-only log: topics split into partitions; consumers track their own offset |
| Wire protocol | MQTT 3.1.1 / 5.0, 2-byte minimum header, designed for flaky low-bandwidth links | AMQP 0-9-1 (also MQTT, STOMP, AMQP 1.0 via plugins; Streams protocol) | Kafka binary protocol over TCP |
| Delivery guarantees | Per-message QoS 0 (at most once), QoS 1 (at least once), QoS 2 (exactly once, 4-way handshake). QoS applies per hop: publisher to broker and broker to subscriber are negotiated separately, and the subscriber gets the lower of the two, so it is not end-to-end | At-least-once with publisher confirms + manual acks; at-most-once with auto-ack. No exactly-once | At-least-once by default; exactly-once within Kafka via idempotent producer + transactions (read_committed) |
| Ordering | Per topic per client session, with caveats on QoS 1 redelivery | FIFO per queue with a single consumer; competing consumers and requeues break global order | Per partition only; the partition key (e.g. orderId) decides which messages stay ordered |
| Retention / replay | None by design: only retained message (last value per topic) and queued QoS 1/2 messages for persistent sessions | Classic and quorum queues delete a message once acked, so no replay. Stream queues (built in since 3.9, x-queue-type: stream) keep an append-only log that consumers read from an offset, which does allow replay | Kept for a time/size window or compacted forever; any consumer can rewind its offset and replay |
| Throughput / latency | Millions of mostly idle connections; tiny payloads; latency dominated by the network | Tens of thousands of msgs/s per queue; lowest per-message latency at moderate load | Hundreds of thousands to millions of msgs/s per cluster via sequential disk I/O and batching; latency in single-digit to tens of ms |
| Consumer model | Push to subscribed sessions | Push with prefetch (basic.qos) | Pull, in batches, by consumer group |
| Typical use | IoT / devices / mobile: telemetry, sensors, commands to devices, last-will presence | Task queues, commands, RPC, flexible routing, per-message retry and DLX | Event streaming: event sourcing, CDC, analytics pipelines, audit log, feeding many independent readers |
When to pick which
- MQTT when the clients are constrained devices on unreliable networks: battery-powered sensors, vehicles, phones. Features built for that world: keep-alive, Last Will and Testament (the broker publishes "device offline" on an ungraceful disconnect), retained messages so a new subscriber immediately sees the last reading, persistent sessions. It is an edge protocol; it is not where you keep business events.
- RabbitMQ when each message is a unit of work that one consumer should do once: send this email, resize this image, charge this card. You want per-message acks, retry with backoff, dead-lettering, priority, TTL, and routing by key or pattern. Work disappears when done.
- Kafka when the message is a fact that happened and several teams need to read it at their own pace, now and later: order history for analytics, CDC out of a database, event sourcing, rebuilding a read model by replaying from offset 0. Ordering per key and replay matter more than per-message routing.
- Common combination: devices speak MQTT to an edge broker, a bridge/connector forwards into Kafka for streaming and storage, and back-office commands go through RabbitMQ. RabbitMQ itself can terminate MQTT via the
rabbitmq_mqttplugin, which is fine for moderate device counts but is not a replacement for a dedicated MQTT broker at millions of connections.
A: MQTT is a lightweight pub/sub broker: clients publish to hierarchical topics and every matching subscriber gets the message, with no durable history. RabbitMQ is a queue broker: exchanges route messages into queues, and a message is deleted once a consumer acks it. Kafka is a distributed append-only log: messages are written to partitions and kept for a retention period, and each consumer group tracks its own offset.
A: QoS 0 is at most once: fire and forget, no ack. QoS 1 is at least once: the receiver acks with PUBACK and the sender retransmits until it arrives, so duplicates are possible. QoS 2 is exactly once through a four-step handshake (PUBLISH, PUBREC, PUBREL, PUBCOMP). QoS applies per hop (client to broker, broker to subscriber), not end to end, and the effective level is the lower of the publisher's and the subscription's.
A: RabbitMQ keeps FIFO order per queue, but competing consumers, prefetch and requeues on failure mean processing order is not guaranteed once you scale out. Kafka guarantees order only within a partition, so you choose a partition key (for example orderId) and every event for that key is processed in order by one consumer in the group; there is no order across partitions.
A: Kafka does not delete a message when it is read; it keeps the log for a time or size window (or compacts it by key) and each consumer group only stores an offset, so resetting the offset re-reads the history. RabbitMQ's classic and quorum queues delete a message once it is acked, so the history is gone; only RabbitMQ Streams offer log-style replay.
A: A retained message is the last message the broker keeps for a topic and delivers immediately to any new subscriber, so a dashboard sees the current temperature without waiting for the next reading. A Last Will and Testament is a message a client registers at connect time; the broker publishes it if the client disconnects ungracefully (missed keep-alive), which is how device-offline presence is detected.
A: When messages are events many independent consumers read at their own pace and you need replay, per-key ordering and very high throughput: event sourcing, CDC, analytics pipelines, audit logs. Pick RabbitMQ when messages are tasks or commands that one consumer should process once, with per-message ack, retry, dead-lettering and flexible routing.
A: MQTT fits constrained devices on unreliable networks: tiny headers, keep-alive, persistent sessions, retained messages and last will make it ideal for IoT telemetry and device commands. It has no log and no replay, so business events should be bridged from the MQTT broker into Kafka or a database for storage and downstream processing.
A: MQTT QoS 2 gives exactly once delivery on a single hop only. RabbitMQ offers at-least-once, so exactly-once effect needs an idempotent consumer (dedupe on message id, inbox table). Kafka offers exactly-once within Kafka using the idempotent producer plus transactions and read_committed consumers; once a side effect leaves Kafka (an HTTP call, an email) you are back to idempotency.
Interview talking points
- "What does MassTransit add over raw RabbitMQ.Client?" — Type-safe contracts, declarative topology, consumer-as-class with DI, automatic OTel, retry/redelivery, sagas, outbox. You're trading framework opinions for ~10× less code per consumer.
- "How does the outbox pattern prevent dual-write inconsistency?" —
Publishinside an EF Core transaction writes a row toOutboxMessageinstead of going straight to RabbitMQ. AfterSaveChangescommits, a background process reads the table and publishes. If the DB rollback wins, no message; if the publish wins, the row is there to retry. - "When would you use a saga vs an event-driven choreography?" — Choreography (each service reacts to events) is simpler for short flows. Saga (centralized state machine) is better when you need to remember "where we are" across multiple steps with timeouts, compensations, and complex branching.
- "How do you change a queue's arguments in production?" — You can't in-place. Either delete and redeclare (with downtime), or blue-green: new queue, dual-publish, drain old, retire.
- "Why version message contracts?" — Producer-consumer deploys are decoupled. A new consumer can't unsafely change the shape because old producers are still emitting V1 elsewhere. V2 lives in parallel until V1 is fully retired.
- "What does PrefetchCount do?" — Caps the number of unacked messages the broker delivers to a single consumer connection. Too high → memory bloat, redelivery storms on crash. Too low → starvation. Tune to handler latency: fast handlers want 32–128, slow ones want 1–4.
- "Sagas with persistence vs in-memory?" — In-memory loses state on restart. EF Core / Redis / Marten persist state per
CorrelationId. Always persist in production.
Further reading
- MassTransit documentation
- RabbitMQ in Depth (book)
- Pat Helland's "Life beyond Distributed Transactions" — context for the outbox pattern