This guide extends the chat pattern from the in-memory guide by replacing the in-process state with two external systems: a persistence layer that loads message history on startup, and RabbitMQ that delivers new messages to all connected server instances in real time.
The observable query and the React component are unchanged from the in-memory version. All the differences are in how the backend populates and pushes the BehaviorSubject.
By the end you will have:
A ChatRoom that loads its initial history from a persistence layer
A ChatSubscriber background service that consumes messages from a RabbitMQ exchange and routes them to the appropriate room
A SendMessage command that publishes to RabbitMQ rather than writing directly to the room
The same observable query and React component from the in-memory guide, unchanged
SendMessage publishes to RabbitMQ and returns. The ChatSubscriber background service, running on every server instance, receives the message from the queue and routes it to the correct ChatRoom. Because ChatRoom updates its BehaviorSubject, every active relay subscription fires and pushes the updated history to connected clients.
ChatRoom is initialized with a pre-loaded history and exposes a Receive() method that the ChatSubscriber calls when a new message arrives from RabbitMQ.
Features/Chat/ChatRoom.cs
usingSystem.Collections.Concurrent;
usingSystem.Reactive.Subjects;
namespaceMyApp.Chat;
/// <summary>
/// Holds the message history and live subject for a single chat room,
/// pre-populated from the persistence layer on creation.
ChatSubscriber runs for the lifetime of the application. It declares a transient queue bound to the chat.messages fanout exchange, consumes every published message, and routes it to the correct ChatRoom.
Using a fanout exchange with an exclusive, auto-delete queue means every server instance receives every message — the right behaviour for a live chat system where clients may be connected to any instance.
Features/Chat/ChatSubscriber.cs
usingSystem.Text.Json;
usingMicrosoft.Extensions.Hosting;
usingRabbitMQ.Client;
usingRabbitMQ.Client.Events;
namespaceMyApp.Chat;
/// <summary>
/// Background service that consumes chat messages from RabbitMQ and routes them
The observable query is structurally identical to the in-memory version. The only change is that SendMessage now publishes to RabbitMQ rather than calling ChatService directly. The ChatSubscriber will receive the message and call ChatRoom.Receive(), which fires the BehaviorSubject.
Features/Chat/ChatRoomPage.cs
usingCratis.Arc.Commands.ModelBound;
usingCratis.Arc.Queries.ModelBound;
usingSystem.Reactive.Subjects;
usingSystem.Text.Json;
usingRabbitMQ.Client;
namespaceMyApp.Chat;
// ─── Read Model ───────────────────────────────────────────────────────────────
/// <summary>
/// Represents a single chat message.
/// </summary>
/// <param name="User">The display name of the sender.</param>
/// <param name="SentAt">The UTC time the message was sent.</param>
Run dotnet build after saving. The proxy generator produces the same ForRoom.ts, SendMessage.ts, and ChatMessage.ts as the in-memory version — the frontend does not change.
The React component is identical to the one in the in-memory guide. The observable query contract — ForRoom.use({ roomName }) returning messagesResult.data — does not change regardless of how the backend sources its data.
The observable query and the frontend are unaffected by the backend data source. That is the point: ISubject<IEnumerable<ChatMessage>> is a contract between the query method and the framework, not between the query method and any specific storage technology.