Index · Additional notes

6 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 AddEntityFrameworkOutboxPublish 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 consumersAddConsumer 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 semanticsPublish = 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

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