Skip to content
Kumar Chandrachooda
Microservices

One Orchestrator Subscribes to Everything

Operations subscribes to every command and event in the estate by reflection and routes them through two open-generic handlers - a neat trick with one dead limb the wiring can never light. Part 8 of Nine Services and a Message Bus.

By Kumar Chandrachooda 08 Sep 2025 5 min read
One box wired to every message type by a reflective scan

The checkout saga and the discount saga both react to events produced all over the estate — OrderCreated, ProductsReserved, CustomerCreated, OrderApproved. For a saga to react to a message, Operations must first subscribe to it on the bus. Every other service subscribes to a hand-written list: .SubscribeCommand<CreateOrder>().SubscribeEvent<CustomerCreated>() and so on. Operations subscribes to a list too — except its list is “everything,” and it builds it by reflection. This part reads that trick, and the one place it quietly fails.

The one-line subscription

Every other service's Configure method spells out each subscription. Operations' is one call:

app.UseRabbitMq()
    .SubscribeAllMessages();

SubscribeAllMessages is an extension that reflects over Operations' own assembly, finds every message type, and subscribes to each one:

private static IBusSubscriber SubscribeAllMessages<TMessage>
    (this IBusSubscriber subscriber, string subscribeMethod)
{
    var messageTypes = MessagesAssembly
        .GetTypes()
        .Where(t => t.IsClass && typeof(TMessage).IsAssignableFrom(t))
        .Where(t => !ExcludedMessages.Contains(t))
        .ToList();

    messageTypes.ForEach(mt => subscriber.GetType()
        .GetMethod(subscribeMethod)
        .MakeGenericMethod(mt)
        .Invoke(subscriber, new object[]
            { mt.GetCustomAttribute<MessageNamespaceAttribute>()?.Namespace, null, null }));

    return subscriber;
}

Read it slowly, because it is doing real reflection work.

GetTypes().Where(... IsAssignableFrom ...) — scan the assembly, keep every class that is an ICommand (called once) or an IEvent (called again). Operations keeps a local copy of every message in the estate — the whole Messages/ tree of Customers, Products, Orders, Identity events — precisely so this scan finds them all. The copy-the-contract convention from Part 4 is what makes “subscribe to everything” mean “everything the orchestrator declared.”

MakeGenericMethod(mt).Invoke(...) — the generic SubscribeCommand<T> / SubscribeEvent<T> method has to be closed over each runtime type mt, which you cannot do with normal generics because mt is a Type, not a compile-time argument. So the code reaches for MakeGenericMethod and invokes it reflectively, passing the message's [MessageNamespace] value as the namespace and null for the rest. This is the hand-rolled dispatch trick the companion series covers in Hand-Rolled MediatR in Thirty Lines, applied to subscription rather than handling.

ExcludedMessages — a three-element set: OperationPending, OperationCompleted, OperationRejected. Operations excludes its own output events, because subscribing to them would make it consume what it publishes and loop. Note that trio; one of them is about to matter.

Two handlers for every message in the estate

Subscribing to a hundred message types would normally mean a hundred handler classes. Operations writes two. It registers open-generic handlers against the framework's handler interfaces:

builder.RegisterGeneric(typeof(GenericEventHandler<>)).As(typeof(IEventHandler<>));
builder.RegisterGeneric(typeof(GenericCommandHandler<>)).As(typeof(ICommandHandler<>));

GenericEventHandler<T> is IEventHandler<T> for any T. When an OrderCreated arrives, the container resolves GenericEventHandler<OrderCreated>; when a ProductsReserved arrives, GenericEventHandler<ProductsReserved>. One class, closed over whatever showed up. Inside, it does two things — feed the saga coordinator, and record the operation's outcome:

public async Task HandleAsync(T @event, ICorrelationContext context)
{
    if (@event.BelongsToSaga())
    {
        var sagaContext = SagaContext.FromCorrelationContext(context);
        await _sagaCoordinator.ProcessAsync(@event, sagaContext);
    }

    switch (@event)
    {
        case IRejectedEvent rejectedEvent:
            await _operationPublisher.RejectAsync(context, rejectedEvent.Code, rejectedEvent.Reason);
            return;
        case IEvent _:
            await _operationPublisher.CompleteAsync(context);
            return;
    }
}

BelongsToSaga() is itself a reflective check — it asks whether any saga type in the assembly implements ISagaAction<T>, so a message only reaches the coordinator if some saga actually cares. The rest is the operation-status side: every event either completes or rejects the operation it belongs to, and publishes an OperationCompleted or OperationRejected so the front end can stop waiting.

To be fair, this is a genuinely elegant reduction. Two handler classes and one reflective subscription replace what would otherwise be dozens of near-identical files. The open-generic-handler-plus-reflective-subscribe combination is the right tool for “one component reacts uniformly to many message types,” and Operations uses it well.

It is worth naming what the reflection costs, because the trade is real. Reflective subscription runs at startup, so a hundred MakeGenericMethod().Invoke() calls are a one-time boot expense, not a per-message one — cheap enough. The sharper cost is that you have traded compile-time safety for runtime convenience: add a new message class to Operations' Messages/ tree and it is automatically subscribed, whether or not any saga handles it, whether or not you meant it to be. The BelongsToSaga() check softens this — a message with no ISagaAction<T> still flows through GenericEventHandler but skips the coordinator, only touching the operation-status side — yet nothing stops an unwanted subscription from binding a queue and consuming messages you never intended Operations to see. Explicit subscription lists, like the other eight services keep, are more typing and more honest: you can read exactly what a service listens to. Operations chose “listen to everything, decide per message” and accepts that its subscription surface is whatever its assembly happens to contain.

The dead limb

Now look at the switch again. It has two arms: IRejectedEvent → reject, everything else → complete. There is no arm that sets an operation pending. And _operationPublisher has a PendingAsync method that would publish an OperationPending event — but nothing in this handler, or anywhere else, calls it.

Trace the consequence. OperationPending is one of the three types in ExcludedMessages, deliberately kept out of the subscribe-to-everything scan because Operations is supposed to publish it, not consume it. There is a whole downstream feature waiting for it: the Signalr service subscribes to OperationPending to push a “your request is being processed” message to the browser, and the frontend has a handler for it. The event is defined, excluded from self-subscription so it can be published cleanly, and awaited by a service and a browser on the far end — and the one place that would publish it never does. The middle of a three-part feature is missing, so the two ends can never light up. An operation in DShop goes straight from nonexistent to completed-or-rejected; it is never, observably, pending.

This is the estate's most instructive dead limb because it is not obviously dead. Every individual piece exists and looks wired: the publisher method, the exclusion, the subscriber, the browser handler. Only by tracing the whole path do you find the gap — the caller. The full autopsy, including the Signalr fan-out and the frontend that falls back to Task.Delay because the push never comes, is in the companion estate series' The Push Channel Nobody Plugged In. From Operations' side, the finding is simpler and sharper: a feature can be fully built at both ends and still be dead, if nobody calls the function in the middle.

The rule of thumb

  • Open generics plus a reflective subscribe is the right shape for "one component, many message types." Two handler classes covering the whole estate is not a hack; it is the correct reduction, and Operations earns it.
  • A wired feature is not a working feature until you trace the caller. Publisher, subscriber, and UI can all exist and pass review; the thing that makes it real is the one line that actually invokes the publish, and that is the line easiest to never write.

Operations drives sagas that publish commands the other services must handle — often more than once, because the bus redelivers. Next: Idempotency Aspired, Not Enforced, where a redelivered reservation decrements the stock twice.