Index · Additional notes
6 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`)
- 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 |
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