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. ITransportCapabilities lets 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/ServiceBusClient through 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.

➡️ 013 — The Inbox Pattern