Command or Event, Enforced by Convention
In DShop.Common a command and an event are published by identical code - the difference between them is a marker interface and a promise nothing enforces.
Part two established that a message's type decides its exchange, routing key and queue. This part is about a distinction the type system makes very loudly and the transport ignores completely: the difference between a command and an event. In most messaging vocabularies these are opposites — a command is an imperative sent to exactly one owner (“reserve this stock”), an event is a fact broadcast to anyone who cares (“stock was reserved”). DShop.Common encodes both, insists on the difference in its type signatures, and then publishes them with the same two lines.
The whole publisher
BusPublisher is the class every service uses to put a message on the bus. In its entirety, minus the constructor:
public async Task SendAsync<TCommand>(TCommand command, ICorrelationContext context)
where TCommand : ICommand
=> await _busClient.PublishAsync(command, ctx => ctx.UseMessageContext(context));
public async Task PublishAsync<TEvent>(TEvent @event, ICorrelationContext context)
where TEvent : IEvent
=> await _busClient.PublishAsync(@event, ctx => ctx.UseMessageContext(context));
Put the two method bodies side by side and cover the signatures. They are identical. Both call _busClient.PublishAsync — RawRabbit's one publish operation — with the message and a lambda that attaches the correlation context. There is no second exchange, no different delivery mode, no mandatory flag on one and not the other. A command and an event travel down exactly the same pipe; the only thing that differs is the word in the method name and the constraint on the generic parameter.
And the constraints point at nothing. Here are the two types that supposedly hold the distinction:
//Marker
public interface ICommand : IMessage { }
//Marker
public interface IEvent : IMessage { }
Both are empty. Both extend an equally empty IMessage. The source even labels them //Marker — they carry no members, no metadata, no behaviour. ICommand and IEvent exist purely so the compiler can tell you which of SendAsync and PublishAsync you are allowed to call. The semantic weight — imperative versus fact, one handler versus many — lives entirely in the developer's head and the team's discipline.
What the convention promises and cannot enforce
The command/event split carries three promises, none of which the framework checks:
- A command has exactly one handler. Nothing stops two services from each declaring a copy of the same command type and both subscribing. RabbitMQ will happily fan a “command” out to two competing-consumer groups, and you have silently turned an imperative into a broadcast.
- An event has zero or more subscribers and the publisher doesn't care who. True here — but also true of commands, because they share the transport. The publisher of a
ReserveProductscommand has no more delivery guarantee than the publisher of anOrderCreatedevent; both are fire-and-forgetPublishAsyncwith no confirm awaited. - The direction of coupling. A command couples the sender to a capability (“someone can reserve stock”); an event couples subscribers to the publisher's past (“stock was reserved”). That is the distinction worth protecting, and it survives only as long as everyone picks the right marker interface and the right verb tense in the class name.
To be fair to the design, this minimalism is a defensible position, and it is the one the mature framework kept. Convey also ships bare ICommand/IEvent markers; the CQRS community broadly agrees that command and event are modelling distinctions, not necessarily transport ones, and that encoding them as marker interfaces documents intent at compile time for almost no cost. A team that treats “commands go to a direct exchange, events to a topic exchange” as gospel would find this estate under-specified; a team that treats the distinction as a naming discipline would find it exactly enough. What reading the source changes is the illusion that the framework is protecting the distinction. It is not. It is handing you two labelled envelopes and one chute, and trusting you to remember which is which.
Two classes, one difference
Make it concrete. Here are a command and an event as they actually appear in a service — a request to do something, and the fact that it was done:
public class ReserveProducts : ICommand
{
public Guid OrderId { get; }
public IEnumerable<ReservedProduct> Items { get; }
}
public class ProductsReserved : IEvent
{
public Guid OrderId { get; }
public IEnumerable<ReservedProduct> Items { get; }
}
Structurally they are twins — same properties, same immutability, same serialised JSON on the wire. Cover the class names and the marker interface and you cannot tell which is which, because the only difference is : ICommand versus : IEvent and the tense of the name. Rename ProductsReserved to ReserveProducts and swap the marker and you have silently converted a fact into an imperative, with no compiler complaint and no runtime difference until a human notices the fan-out is wrong. The distinction lives in two places the machine doesn't police: the suffix of an empty interface and the grammar of a class name.
The subscribe side tells the same story
The distinction reappears — and again is only a type dance — on the receiving end, in BusSubscriber:
public IBusSubscriber SubscribeCommand<TCommand>(...) where TCommand : ICommand
{
_busClient.SubscribeAsync<TCommand, CorrelationContext>(async (command, ctx) =>
{
var handler = _serviceProvider.GetService<ICommandHandler<TCommand>>();
return await TryHandleAsync(command, ctx, () => handler.HandleAsync(command, ctx), onError);
});
return this;
}
public IBusSubscriber SubscribeEvent<TEvent>(...) where TEvent : IEvent
{
_busClient.SubscribeAsync<TEvent, CorrelationContext>(async (@event, ctx) =>
{
var handler = _serviceProvider.GetService<IEventHandler<TEvent>>();
return await TryHandleAsync(@event, ctx, () => handler.HandleAsync(@event, ctx), onError);
});
return this;
}
Same shape twice. SubscribeCommand resolves an ICommandHandler<T>; SubscribeEvent resolves an IEventHandler<T>. Both funnel into the same TryHandleAsync — the retry-and-reject machinery that is part five's subject. So the framework's entire respect for the command/event distinction is: it will look up a different handler interface in the container. Two GetService calls that differ by one generic type. Everything downstream — the retry policy, the error taxonomy, the acknowledgement — is byte-for-byte the same code.
The distinction is real, it matters, and it is enforced by convention rather than by the compiler or the broker. That is not a bug; it is a design register. But it means the health of the command/event model in this estate is entirely a function of how carefully nine service teams named their classes and picked their markers — which is precisely the seam where the second series finds an OrderCreated that means one thing in the service that publishes it and something narrower in the service that consumes it.
Both publish methods attach an ICorrelationContext — the envelope that threads a single request's identity through every hop of the bus. It is the most-copied object in the estate, and it has a bug that has been riding along in production traces the whole time. That is next.