Swap the Dispatcher for a Bus
The monolith and the microservices share the same command names. Decomposition here was one substitution - resolve a local handler, or publish to a broker - and reading both dispatchers shows exactly what changes.
The cleanest way to understand how a monolith becomes a set of services is to find a system that did both and diff the seam. DShop is that system, and the seam is remarkably thin. The monolith and the distributed build share the same command vocabulary — CreateOrder, SignUp, AddProductToCart, ReserveProducts — because the distributed build was decomposed from the same mental model. What changed between them is not the commands and not the handlers. It is one object in the middle: the dispatcher. In the monolith it resolves a local handler and calls it; in the swarm it publishes to RabbitMQ. Everything else in the decomposition is downstream of that one substitution.
The dispatcher in thirty lines
The monolith's command side is genuinely small. A command is a marker interface, a handler is a one-method generic, and the dispatcher is a resolve-and-call:
public interface ICommandHandler<in T> where T : ICommand
{
Task HandleAsync(T command);
}
public class CommandDispatcher : ICommandDispatcher
{
private readonly IComponentContext _context;
public CommandDispatcher(IComponentContext context) => _context = context;
public async Task DispatchAsync<T>(T command) where T : ICommand
{
var handler = _context.Resolve<ICommandHandler<T>>();
if (handler == null)
throw new ArgumentException(
$"Command handler: '{typeof(T).Name}' was not found.", nameof(handler));
await handler.HandleAsync(command);
}
}
Read it slowly, because the shape recurs three times in this estate. The dispatcher holds Autofac's IComponentContext — the container itself — and uses it as a service locator: given a command of compile-time type T, resolve the single ICommandHandler<T> registered for it and invoke HandleAsync. This is a hand-rolled MediatR, the same pattern DShop.Common implements for the distributed side and that Series 1 reads line by line. The generic parameter does the routing: there is exactly one handler per command type, the container finds it, the call happens on the current thread, inside the current request, inside the current Mongo connection. If the handler throws, the exception propagates straight back up the call stack to the controller.
The null check is worth a smile: Autofac's Resolve<T> never returns null — it throws if nothing is registered — so the ArgumentException branch is unreachable. A defensive guard against a failure mode the framework already handles differently. Harmless, but a tell that the code was written from the shape of the pattern rather than from the behaviour of the container.
Events go wide, and reflection carries them
Commands have one handler; events can have many, and the monolith's EventDispatcher shows the second shape — a fan-out over reflection:
public async Task DispatchAsync(params IEvent[] events)
{
foreach (var @event in events)
{
var eventType = @event.GetType();
var handlerType = typeof(IEventHandler<>).MakeGenericType(eventType);
await DispatchAsync(handlerType, @event);
}
}
private async Task DispatchAsync(Type handlerType, IEvent @event)
{
if (_context.TryResolve(handlerType, out object handler))
{
var method = handler.GetType().GetMethod("HandleAsync");
await (Task)method.Invoke(handler, new object[] { @event });
}
}
The command dispatcher could stay strongly typed because T is known at the call site. The event dispatcher cannot: events arrive as IEvent[], their concrete types known only at runtime, so it reaches for reflection — MakeGenericType to build IEventHandler<SignedUp> from a runtime Type, TryResolve to find a handler if one exists, GetMethod("HandleAsync").Invoke to call it. The TryResolve is deliberate: unlike a command, an event with no handler is fine — it just means nobody in this process cares about it yet. That single design choice — commands must have a handler, events may have none — is the same rule the distributed bus enforces, except the bus enforces it with queue bindings instead of container registrations.
This is the exact double-dispatch-by-reflection trick DShop.Common's query dispatcher uses on the distributed side, and it is worth noting the estate reinvents it in the monolith too. The pattern travels; only the transport underneath it changes.
What makes the resolve work
Both dispatchers lean on the container knowing every handler, and the registration is one assembly scan:
var servicesAssembly = Assembly.GetExecutingAssembly();
builder.RegisterAssemblyTypes(servicesAssembly)
.AsImplementedInterfaces()
.InstancePerLifetimeScope();
RegisterAssemblyTypes(...).AsImplementedInterfaces() sweeps the assembly and registers every concrete type against each interface it implements — so CreateOrderHandler is automatically registered as ICommandHandler<CreateOrder> with no per-handler wiring. Add a handler class, and the dispatcher can find it next boot; there is no registry to update. This is the same convention-over-configuration move the distributed chassis makes, and it is what lets the dispatcher stay a three-line service locator: the container has already done the matching. InstancePerLifetimeScope ties each handler's lifetime to the request, so the Mongo session it shares with its repositories is the request's session — the quiet reason the monolith's in-process handling is transactionally coherent in a way the bus can never be for free.
The same command, two roads
Now put the two builds next to each other. In the monolith, OrdersController.Post dispatches CreateOrder and the CreateOrderHandler runs immediately, in-process, reading the cart and writing the order against the same Mongo:
[HttpPost]
public async Task<IActionResult> Post([FromBody] CreateOrder command)
=> await DispatchAsync(command.BindId(c => c.Id).Bind(c => c.CustomerId, UserId),
resourceId: command.Id, resource: "orders");
In the distributed build, the gateway takes the identical CreateOrder command and publishes it to RabbitMQ's orders exchange with a routing key derived from the type name. The Orders service, on the far side of a queue binding, resolves its ICommandHandler<CreateOrder> and runs it. The controller's line barely changes; the semantics change entirely:
- Failure. In the monolith, a throwing handler surfaces as an HTTP error on the same request. Across the bus, the caller has already received its 202; a failure now has to travel back as a rejected event, and something has to be listening for it.
- Ordering. In-process,
CreateOrdercompletes before the next line runs. Across the bus, two commands for the same cart can be processed concurrently by competing consumers, and nothing serialises them. - Consistency. The monolith's handler reads the cart and writes the order in one connection; either both happen or the exception rolls the request back. The distributed handler reserves products in another service over another message, and there is no transaction spanning the two.
The command class did not change. The word “dispatch” did all the lying.
What the substitution costs
The seductive thing about “swap the dispatcher for a bus” is how little code it touches. You keep your commands, your handlers, your HandleAsync signature; you replace one class that resolves a handler with one class that publishes a message. The monolith and DShop's services are living proof that the refactor is small. The trap is believing the small refactor bought you a small change.
It bought you the entire distributed-systems syllabus. The in-memory dispatcher gave you, for free, exactly-once handling, transactional consistency, in-order processing, and synchronous error propagation. The bus gives you none of those and asks you to build each one back: idempotent receivers because redelivery is now possible, sagas because there is no transaction, an operation/status channel because the caller left before the work finished. DShop builds some of that — the sagas are real, the operation channel exists — and leaves the rest as the honest gaps this series catalogues.
The rule of thumb: the dispatcher swap is a one-line change that relocates every hard problem from the compiler to the network. If you are decomposing a monolith and the migration plan reads “just publish the commands instead of dispatching them,” you have described the easy 10% and named none of the 90%.
Next, a decomposition that changed more than the plumbing: who creates the customer? — where moving one handler across a boundary quietly rewrote the sign-up flow.