skip to content

How do you build an order-status subscription in Hot Chocolate 16 that still delivers events when the API runs on several instances?

level: seniorimportance: should knowfreq 25%

answer

  1. publish on one side, stream on the other
  2. [Subscribe] with an [EventMessage] parameter
  3. a topic per order via [Topic]
  4. ITopicEventSender after SaveChanges
  5. in-memory stays in one process

basics

~10 s

Declare a [Subscribe] method with a [Topic("OrderStatus_{orderId}")] pattern and an [EventMessage] Order parameter, publish with ITopicEventSender.SendAsync after saving, and register a distributed provider such as AddRedisSubscriptions instead of AddInMemorySubscriptions.

solid answer

~40 s

In Hot Chocolate I write a `[SubscriptionType] public static partial class OrderSubscriptions` with `[Subscribe] [Topic("OrderStatus_{orderId}")] public static Order OrderStatusChanged(int orderId, [EventMessage] Order order) => order;`. The `{orderId}` placeholder is filled from the argument, so each client listens only to its own order. The `updateOrderStatus` mutation saves, then calls `sender.SendAsync($"OrderStatus_{order.Id}", order, ct)` on the injected `ITopicEventSender`. Transport needs `app.UseWebSockets()` before `MapGraphQL()` for graphql-ws; SSE needs nothing extra. The multi-instance part is the **provider**: `AddInMemorySubscriptions()` delivers only inside one process, so an event published on instance A never reaches a client connected to B. With several instances I register a shared one: `AddRedisSubscriptions(...)`, `AddNatsSubscriptions()` or `AddPostgresSubscriptions(...)`, and keep the payload serialisable.

code

csharp · 9 lines
csharp
builder
    .AddGraphQL()
    .AddTypes()
    .AddRedisSubscriptions(redisConnectionFactory);

var app = builder.Build();
app.UseWebSockets();
app.MapGraphQL();
app.Run();

go deeper

for a junior

Recall the attributes [SubscriptionType], [Subscribe] and [EventMessage], and that a mutation publishes each event with ITopicEventSender.SendAsync after saving.

for a middle

Explain default and dynamic topics, how the argument placeholder is filled, and why UseWebSockets must come before MapGraphQL.

for a senior

Choose a provider for a multi-instance deployment, keep payloads serialisable and small, publish after commit, and plan for reconnecting clients.

for a principal

Decide whether GraphQL subscriptions or a separate event channel should carry order updates, weighing broker operations, delivery guarantees and client complexity.

## The pieces of a Hot Chocolate subscription A GraphQL subscription is a long-lived request that receives a **stream** of results. In Hot Chocolate the stream is fed by a **pub/sub provider**: - a **publisher** sends a message to a named **topic** through `ITopicEventSender`; - a **subscription field** marked `[Subscribe]` listens to a topic through `ITopicEventReceiver` and turns each message into a GraphQL result; - the **provider** moves messages from publishers to listeners — in memory, or through an external broker. ## Declaring the order-status field ```csharp [SubscriptionType] public static partial class OrderSubscriptions { [Subscribe] [Topic("OrderStatus_{orderId}")] public static Order OrderStatusChanged(int orderId, [EventMessage] Order order) => order; } ``` - `[Subscribe]` marks the field as topic-backed; `[EventMessage]` marks the parameter that receives the payload. - Without `[Topic]`, the topic is the **method name** (`OrderStatusChanged`), so every subscriber gets every order's events. - With a **dynamic topic**, each `{argumentName}` placeholder is replaced with the client's argument value when it subscribes: `orderStatusChanged(orderId: 42)` listens on `OrderStatus_42`. A fixed prefix keeps topics from colliding with other fields keyed by the same id. - The method body runs for every message, so it can map or drop the payload. Nested fields such as `order { lines { book { title } } }` then resolve per event; DataLoaders are created fresh for each event. - `[Subscribe(With = nameof(...))]` points at a custom method returning `ValueTask<ISourceStream<T>>` when a topic needs runtime logic. ## Publishing from the mutation ```csharp [MutationType] public static partial class OrderMutations { public static async Task<Order> UpdateOrderStatusAsync( int orderId, OrderStatus status, BookstoreContext db, ITopicEventSender sender, CancellationToken ct) { var order = await db.Orders.SingleAsync(o => o.Id == orderId, ct); order.Status = status; await db.SaveChangesAsync(ct); await sender.SendAsync($"OrderStatus_{order.Id}", order, ct); return order; } } ``` Publish **after** the write commits, so a subscriber never sees a status the database then rolls back. `ITopicEventSender` is the same abstraction for every provider, so the publishing code does not change when the provider does; it can be injected anywhere, not only in mutations. ## Transport 1. **WebSocket**: add `app.UseWebSockets()` before `app.MapGraphQL()`. Hot Chocolate speaks the `graphql-ws` protocol and the legacy `subscriptions-transport-ws`. 2. **Server-Sent Events**: the `graphql-sse` protocol works on the mapped endpoint with no extra middleware. ## What the client does, step by step ```graphql subscription { orderStatusChanged(orderId: 42) { id status } } ``` 1. The client opens a WebSocket (or an SSE request) to `/graphql` and sends this operation. 2. Hot Chocolate evaluates the topic pattern with `orderId = 42` and subscribes to `OrderStatus_42` through `ITopicEventReceiver`. 3. Nothing is sent until a message arrives; the connection stays open. 4. The mutation publishes to `OrderStatus_42`; the provider delivers the message to every stream on that topic. 5. For each message, the `[Subscribe]` method runs, the selection set `{ id status }` is resolved against the returned `Order`, and one result is pushed. 6. When the client stops or disconnects, the stream is disposed; `ITopicEventSender.CompleteAsync(topic)` ends every stream on a topic from the server side. ## Choosing the provider: the multi-instance question | Registration | Reaches subscribers on other instances | Notes | |---|---|---| | `AddInMemorySubscriptions()` | No | Single process; events lost on restart | | `AddRedisSubscriptions(...)` | Yes | Takes a connection factory | | `AddNatsSubscriptions()` | Yes | Core publish/subscribe; `TopicPrefix` isolates servers sharing a broker | | `AddPostgresSubscriptions(...)` | Yes | Uses LISTEN/NOTIFY on a long-lived, unpooled connection | There is also an `AddRabbitMQSubscriptions` provider. Register exactly one provider. Behind a load balancer, the mutation and the subscriber usually land on different instances, which is why in-memory "works on my machine" and fails in production. ## Production concerns - **Serialisation**: the `AddRedisSubscriptions` and `AddNatsSubscriptions` providers serialise each message to JSON by default. Send a small DTO rather than an EF entity graph with cycles and lazy navigations. - **Missed events**: treat subscription messages as live notifications. A client that reconnects should re-query the order's current status instead of assuming it saw every change. - **Authorisation**: check that the subscriber may watch `orderId` when it subscribes, not only when it queries. - **Connection capacity**: every subscriber holds an open connection on one instance for as long as it listens, so size instances for concurrent connections, not only for requests per second. - **Version 16 detail**: `@skip` and `@include` are not allowed on root subscription fields.

  • What topic does a Hot Chocolate [Subscribe] method listen on when it has no [Topic] attribute?
    Its C# method name. The publisher must therefore send to that exact string, which is why the docs publish with `nameof(OrderSubscriptions.OrderStatusChanged)`. Every subscriber to that field then receives every message, so per-resource delivery needs a `[Topic]` with an argument placeholder.
  • Several Hot Chocolate services registered with AddNatsSubscriptions share one broker and receive each other's order events; how do you stop that?
    Pass `SubscriptionOptions` with a distinct `TopicPrefix` to `AddNatsSubscriptions` in each service. The prefix namespaces the topics on the shared broker, so identical topic strings such as `OrderStatus_42` from different services no longer collide.

The provider is the building's intercom: in-memory is shouting down one hallway, so only people on that floor hear it; a shared broker wires every floor, so the announcement reaches whoever is listening, wherever they sit.

saying these in an interview costs you the question

  • AddInMemorySubscriptions shares events across instances behind a load balancer.
  • Hot Chocolate subscriptions need no middleware for WebSocket clients.
  • A subscription without [Topic] filters events by its arguments automatically.
  • Publishing before SaveChangesAsync is fine because clients will re-query anyway.
  • Distributed providers pass the EF entity object across instances without serialisation.