Articles · Messaging & Reliability ·012
Messaging & Transports
Runnable sample on GitHub
Sample:
samples/012-messaging-and-transports— publish/subscribe over the in-memory transport.dotnet run/dotnet test. No Docker.Prerequisite: 011 — The Outbox Pattern.
Overview
The outbox stages events; a transport carries them. Relay abstracts the broker behind two small
interfaces — IMessageBroker (publish) and IMessageConsumer (subscribe) — with three interchangeable
implementations: in-memory (in-process, for tests and local dev), RabbitMQ, and Azure Service
Bus. The same publish/subscribe code, and the same outbox/inbox processors, run unchanged across all
three; you choose the transport in one DI call. This article covers the abstraction and the in-memory
transport that makes messaging testable without a broker container.
Why this exists
Coupling your code directly to a broker SDK (RabbitMQ’s IModel, Azure’s ServiceBusClient) spreads
broker-specific details — connection management, channels, ack semantics, topology — across your
application, and welds you to that one broker. Switching brokers, or testing without one, becomes a
rewrite. An abstraction with a small surface (publish a message; subscribe a handler) lets the
framework own the broker-specific complexity (publisher confirms, manual ack, redelivery,
dead-lettering, reconnection) behind a uniform contract, and lets you pick the transport per
environment: in-memory in unit tests, RabbitMQ in production, Azure Service Bus in the cloud — without
touching the code that publishes or consumes.
The in-memory transport is the keystone for testability. Messaging is notoriously hard to test because
it usually needs a running broker. An in-process transport with deterministic delivery (messages
delivered on an explicit DrainAsync) makes a publish→consume round-trip a fast, reliable unit test —
no container, no flaky timing — while preserving the same semantics (routing, manual ack, redelivery,
dead-lettering) as the real brokers, so a test that passes in-memory exercises the real contract.
When to use this
- In-memory transport: unit/integration tests of messaging flows, local development, and the reliability test suites (article 014). Anywhere you want broker semantics without a broker.
- RabbitMQ: the default production transport — mature, self-hostable, topic routing, DLX/DLQ, publisher confirms. The right choice for most on-prem or cloud-agnostic deployments.
- Azure Service Bus: when you’re on Azure and want a managed broker with native scheduled delivery and dead-letter queues, and don’t need RabbitMQ’s unroutable-publish detection.
The transport choice is per-environment, not per-feature: pick in-memory for tests, a real broker for production, and write your publish/consume code once.
When not to use this
- In-memory in production / across processes. It is in-process only — messages don’t leave the machine. It is for tests and single-process local dev, never cross-service production messaging.
- No cross-service communication at all. If nothing leaves the service, you don’t need a transport; domain events (article 003) cover in-process reactions.
- You need a broker feature the abstraction doesn’t surface. The abstraction is intentionally small. For deeply broker-specific needs (exotic exchange types, broker-native dedup, custom AMQP headers beyond the framework’s), you may reach the SDK directly — but weigh the coupling you reintroduce.
The cost is a thin layer of indirection and a deliberately limited surface: you get portability and testability, and give up direct access to every broker knob (most of which the framework already manages for you).
Concepts
MessageBrokerMessage. The envelope: MessageId (the dedup key on the consumer side), EventId,
EventType (the routing key / type name), Payload (JSON), OccurredAt, correlation/causation ids, and
Headers (tenant id, claim-check pointer, partition key, delivery-attempt count).
IMessageBroker. PublishAsync(message) / PublishManyAsync(...). Implementations handle
durability and (for RabbitMQ/in-memory) throw UnroutableMessageException when no queue is bound — the
mandatory-publish behavior the outbox relies on to know a message was actually routed.
IMessageConsumer. SubscribeAsync(queueName, routingKeys, handler) with manual-ack semantics:
the message is dropped only after the handler succeeds; on failure it’s redelivered (with an incremented
delivery-attempt header) and dead-lettered at the cap. Optional IMessageConsumerLifecycle signals a
dropped subscription so consumers can resubscribe.
Transport capabilities. ITransportCapabilities negotiates differences: does the transport detect
unroutable messages? support native scheduling? signal lifecycle? push or poll? The framework adapts
(e.g. uses the scheduler for delayed delivery when the transport can’t do it natively).
Deterministic delivery (in-memory). DeterministicDelivery = true queues published messages and
delivers them on DrainAsync(), turning an asynchronous flow into a deterministic, awaitable one for
tests.
Architecture
graph LR
subgraph "Your code (unchanged across transports)"
Pub[IMessageBroker.PublishAsync]
Sub[IMessageConsumer.SubscribeAsync]
end
Pub --> T{Transport}
Sub --> T
T -->|tests / local| IM[InMemory]
T -->|production| RMQ[RabbitMQ]
T -->|Azure| ASB[Azure Service Bus]
Cap[ITransportCapabilities] -.->|unroutable? scheduling? lifecycle?| T
Building it step by step
The sample is samples/012-messaging-and-transports.
1. Register a transport
services.AddRelayInMemoryTransport(o => o.DeterministicDelivery = true);
// production: services.AddRelayRabbitMq(o => { o.HostName = "..."; ... });
2. Subscribe, then publish
var broker = provider.GetRequiredService<InMemoryMessageBroker>();
var consumer = provider.GetRequiredService<IMessageConsumer>();
// Subscribe first so a queue is bound (an unroutable publish throws).
await consumer.SubscribeAsync("orders", Array.Empty<string>(), (message, _) => { received.Add(message.EventType); return Task.CompletedTask; });
await broker.PublishAsync(new MessageBrokerMessage { MessageId = Guid.NewGuid(), EventType = "order.placed", Payload = "{}" });
await broker.DrainAsync(); // deterministic delivery → the handler runs now
In production you rarely call publish/subscribe yourself — the outbox processor publishes and the inbox processor subscribes (articles 011/013/014). This sample uses the raw API to show the transport directly.
Complete source code
| File | Contents |
|---|---|
TransportScenario.cs |
Register the transport; subscribe → publish → drain |
TransportTests.cs |
A published message reaches a subscribed consumer |
Running the example
dotnet run --project samples/012-messaging-and-transports/Transport.Sample
dotnet test samples/012-messaging-and-transports/Transport.Sample.Tests
Testing
Deterministic delivery makes a messaging round-trip a plain, fast unit test — no container, no polling:
[Fact] public async Task A_published_message_is_delivered_to_a_subscribed_consumer()
{
using var provider = TransportScenario.BuildProvider();
var received = await TransportScenario.PublishAndConsumeAsync(provider, "order.placed");
received.Should().ContainSingle().Which.Should().Be("order.placed");
}
Because the in-memory transport mirrors the real brokers’ routing, ack, and redelivery semantics, a test that passes here is exercising the same contract production uses — which is exactly why the framework’s own transport-conformance suite runs the same tests against in-memory and RabbitMQ.
Production considerations
- Never run in-memory across processes. It’s in-process. Use RabbitMQ or Azure Service Bus for real cross-service messaging; reserve in-memory for tests and single-process local dev.
- Let the framework own broker complexity. Publisher confirms, manual ack, redelivery, dead-lettering, reconnection are handled behind the abstraction. Don’t reach for the SDK to reimplement them.
- Mind capability differences. Azure Service Bus doesn’t detect unroutable publishes (a publish to a
topic with no subscription silently succeeds) but supports native scheduling; RabbitMQ is the reverse.
ITransportCapabilitieslets the framework adapt — know which transport you’re on when reasoning about edge cases. - Configure DLQ and prefetch deliberately. Production transports have a dead-letter destination and a prefetch/concurrency setting; tune them (article 014 covers reliability knobs) rather than accepting defaults blindly.
- Health-check the broker. Register the transport’s health check so a down broker fails readiness instead of silently backing up the outbox.
Common mistakes
- Shipping the in-memory transport to production. It works in tests, so it ships — and then nothing crosses a process boundary. Gate the transport by environment.
- Publishing before any consumer is bound and being surprised by
UnroutableMessageException. On RabbitMQ/in-memory, a publish with no matching binding throws (mandatory publish). Ensure topology/ subscriptions exist; in the outbox flow the processor handles this by retrying. - Coupling business code to the broker SDK. Sprinkling
IModel/ServiceBusClientthrough handlers defeats the abstraction and the portability/testability it buys. Publish/consume through the interfaces. - Assuming identical semantics across transports. Unroutable detection and native scheduling differ. Don’t hard-code assumptions; consult capabilities.
- Hand-rolling the consume loop. Manual ack, redelivery, and dead-lettering are subtle and already implemented. Subscribe a handler; let the consumer manage the lifecycle.
Tradeoffs
Benefits. Portable publish/consume code; fast, deterministic messaging tests without a broker; the framework manages broker-specific complexity; and capability negotiation papers over transport differences.
Costs. A thin indirection layer; a deliberately limited surface (some broker-specific features need the SDK); and the discipline to keep broker details out of business code.
Alternatives
- Direct broker SDK (RabbitMQ.Client, Azure.Messaging.ServiceBus). Full access to every feature, at the cost of coupling, repetition, and untestability without a broker. Justified only for genuinely exotic broker needs; for everything else the abstraction wins.
- A different messaging library (MassTransit, NServiceBus, Wolverine). Mature, feature-rich alternatives with their own abstractions. If you’re already invested in one, you may not need Relay’s transport; if you’re building on Relay, its transport integrates natively with the outbox/inbox/sagas.
- HTTP / gRPC between services. Synchronous request/response, not async events. The right tool when you need an immediate answer; the wrong one for fire-and-forget notifications, which want a broker.
Objectively: the transport abstraction wins wherever you value portability and testable messaging and the framework’s managed semantics cover your needs — which is the common case.
Lessons from production systems
- The in-memory transport is the unsung hero of messaging test suites. Teams that adopt it write fast, deterministic tests for flows that others can only test against a live broker (slow, flaky, or skipped entirely). The framework’s own reliability tests run in-memory and on RabbitMQ for exactly this reason.
- Capability differences bite at the edges. The classic surprise is “we published to Azure Service Bus and no one got it” (no subscription, silent success — no unroutable detection). Knowing your transport’s capabilities prevents a class of confusing incidents.
- The abstraction’s value shows up at broker-migration time. Teams that kept broker code behind the interfaces switch transports (or run different ones per environment) with a DI change; teams that reached for the SDK everywhere rewrite.
- Most teams never need the SDK directly. The instinct to “drop down” for control usually means reimplementing something the framework already does (ack, retry, DLQ). Reach for the SDK only after confirming the capability genuinely isn’t surfaced.
Should you use this?
| Situation | Recommendation |
|---|---|
| Testing messaging flows | Strong fit — in-memory + deterministic delivery |
| Production cross-service messaging | Strong fit — RabbitMQ / Azure Service Bus behind the abstraction |
| Local single-process development | Strong fit — in-memory |
| In-memory transport in production | No — in-process only |
| Deeply broker-specific features | Consider the SDK for that narrow need |
| Synchronous request/response | No — use HTTP/gRPC |
Next steps
A transport delivers a message at-least-once — the same message can arrive twice (a redelivery, an outbox re-publish after a crash). The next article handles that on the receiving side: the inbox, which makes consumption idempotent so duplicate deliveries produce a single effect.