.NET RabbitMQ — MassTransit, Topology, Evolution · Additional notes

12 min read
16 min read
Rapid overview

Additional notes

Topology — what MassTransit declares automatically

For each registered consumer, MassTransit declares:

  1. An exchange named after the message type's full CLR name (e.g., Contracts:OrderPlaced).
  2. A queue per consumer endpoint (default: kebab-case of consumer class).
  3. A binding from exchange → queue.
  4. An error queue (<queue>_error) for poison messages.
  5. A skipped queue (<queue>_skipped) for messages with no handler.

All idempotent — re-running on a current broker is a no-op.

Performance characteristics

OperationTypical 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 latencyQueryDelay (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_FAILED on declare. You have two options:
  1. Delete the queue manually, let MassTransit redeclare on next startup
  2. 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

  1. 406 PRECONDITION_FAILED on startup — queue exists with different arguments. Solution: delete-and-redeclare, or version the queue name.
  2. Forgetting AddEntityFrameworkOutbox — Publish inside a transaction sends immediately, not on commit. Dual-write inconsistency follows.
  3. High prefetch + slow handlers — messages sit in the client buffer, redelivery on crash multiplies effective load. Tune prefetch to handler speed.
  4. Saga state not persisted — uses InMemory by default. Restart = lost state. Configure EF Core / Redis / Marten persistence explicitly.
  5. Forgetting to register consumers — AddConsumer is mandatory; otherwise the queue is declared but no one reads it.
  6. No DLX (dead-letter exchange) — failed messages pile up in _error indefinitely. Either drain manually or add a DLX with TTL for auto-cleanup.
  7. Mixing Publish and Send semantics — Publish = fan-out via exchange, Send = point-to-point to a specific queue. Confusing the two leads to "the message disappeared" mysteries.
  8. 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 declarationAutomatic via ConfigureEndpointsManual (amqp091) or via watermill plugin
Type-safe contractsYes (full CLR types)Manual marshal/unmarshal
Consumer patternIConsumer<T> classfunction or watermill.Handler
SagasFirst-class (state machine)Manual (or watermill plugin, limited)
OutboxFirst-class (AddEntityFrameworkOutbox)Manual or watermill-sql
Retry / redeliveryDeclarative pipelineManual (or watermill-rabbitmq middleware)
Scheduled messagesFirst-class (SchedulePublish)Manual (delayed exchange + TTL)
Distributed tracingAuto via OTelManual hook
BoilerplateLowHigh (amqp091) / Medium (watermill)
Footprint~50 MB extra for MassTransit~5 MB for amqp091
MaturityProduction-proven in many EU SaaS shopsDirect 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.

AspectMQTT (Mosquitto, EMQX, HiveMQ)RabbitMQ (AMQP 0-9-1)Kafka
ModelLightweight pub/sub broker: clients publish to hierarchical topics (plant/3/line/7/temp), subscribers match with + / # wildcardsQueue broker: publish to an exchange, routed by bindings into queues; a message is removed once ackedDistributed append-only log: topics split into partitions; consumers track their own offset
Wire protocolMQTT 3.1.1 / 5.0, 2-byte minimum header, designed for flaky low-bandwidth linksAMQP 0-9-1 (also MQTT, STOMP, AMQP 1.0 via plugins; Streams protocol)Kafka binary protocol over TCP
Delivery guaranteesPer-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-endAt-least-once with publisher confirms + manual acks; at-most-once with auto-ack. No exactly-onceAt-least-once by default; exactly-once within Kafka via idempotent producer + transactions (read_committed)
OrderingPer topic per client session, with caveats on QoS 1 redeliveryFIFO per queue with a single consumer; competing consumers and requeues break global orderPer partition only; the partition key (e.g. orderId) decides which messages stay ordered
Retention / replayNone by design: only retained message (last value per topic) and queued QoS 1/2 messages for persistent sessionsClassic 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 replayKept for a time/size window or compacted forever; any consumer can rewind its offset and replay
Throughput / latencyMillions of mostly idle connections; tiny payloads; latency dominated by the networkTens of thousands of msgs/s per queue; lowest per-message latency at moderate loadHundreds 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 modelPush to subscribed sessionsPush with prefetch (basic.qos)Pull, in batches, by consumer group
Typical useIoT / devices / mobile: telemetry, sensors, commands to devices, last-will presenceTask queues, commands, RPC, flexible routing, per-message retry and DLXEvent 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_mqtt plugin, which is fine for moderate device counts but is not a replacement for a dedicated MQTT broker at millions of connections.
Q: What is the core model difference between MQTT, RabbitMQ and Kafka?

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.

Q: What do MQTT QoS levels 0, 1 and 2 mean?

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.

Q: How do ordering guarantees differ between RabbitMQ and Kafka?

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.

Q: Why can a Kafka consumer replay history but a RabbitMQ consumer cannot?

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.

Q: What is an MQTT retained message and a Last Will and Testament?

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.

Q: When would you pick Kafka over RabbitMQ?

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.

Q: When is MQTT the right choice, and why not use it as the system of record?

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.

Q: How does each system achieve exactly-once processing, if at all?

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?" — Publish inside an EF Core transaction writes a row to OutboxMessage instead of going straight to RabbitMQ. After SaveChanges commits, 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