Message Types: Commands, Events, and Queries; Event Notification and Event-Carried State Transfer
A command is imperative and goes to one receiver, an event is a past-tense fact that doesn't know its receivers, and a query expects an answer; that distinction decides a message's name, its contract owner, and its channel. An envelope with message, correlation, and causation ids, Fowler's event notification versus event-carried state transfer, and domain versus integration events, with real output.
Version note: The code in this post was built and run with C# 14 and .NET 10.
Guess First
In the previous post we dropped the confirmation email on a queue. The team liked the idea, and within a few weeks these messages showed up in Bookstore's code:
namespace Bookstore.Before;
public sealed class Order
{
public Guid Id { get; set; }
public Guid CustomerId { get; set; }
public string Status { get; set; } = "Placed";
public string ShippingCity { get; set; } = "";
public decimal Total { get; set; }
public string? CardLast4 { get; set; }
}
public sealed record SendOrderConfirmationEmailEvent(Guid OrderId);
public sealed record OrderUpdated(Order Order);
public sealed record OrderPlacedEvent(Guid OrderId);
The Shipping team wants to start preparing the shipment when an order is placed. Which of these three messages should it listen to? When OrderUpdated arrives, should shipment preparation start, or was the order cancelled, or did the address change? What happens if Shipping starts listening to SendOrderConfirmationEmailEvent too? And one more: what does every team listening to the Order inside OrderUpdated get to see?
A Message: Envelope and Content
The book Enterprise Integration Patterns (EIP: Gregor Hohpe and Bobby Woolf's 2003 catalog of message-based integration patterns) describes a message in two parts: a header used by the messaging system and a body that carries the application's data. The book also separates messages by intent: a command message asks the receiver to do something, a document message transfers data, and an event message announces that something happened. Telling these three apart decides a message's name, its owner, and how many receivers it will have.
Bookstore's first messages were written without that distinction in mind. Let's go through them one by one; first we'll talk about what goes into each message's envelope, then about its content.
Commands, Events, and Queries
A command is an order: "take the payment", "send the confirmation email". Its name is in the imperative (ChargePayment), it has exactly one logical receiver, and the sender expects an outcome. The receiver can refuse: if the card limit isn't enough, ChargePayment fails. Payments takes the payment; two different consumers processing the same command means charging the money twice.
An event is a fact: "an order was placed". Its name is in the past tense (OrderPlaced), it can have zero, one, or many receivers, and the publisher doesn't know them. Something that happened can't be refused; Inventory can't say "I don't accept this order", at most it produces a reaction on its own side (OutOfStock). If Notification wants to listen to OrderPlaced tomorrow, not a single line changes in Ordering.
A query is a question: "How many copies of Dune are there?" An answer is expected. You can build request/reply with messages (in EIP, the Request-Reply pattern, with a Return Address saying where the answer goes and a Correlation Identifier saying which request it belongs to), but as we saw in the previous post, if you are going to wait for the answer inside the same HTTP (Hypertext Transfer Protocol) request, a synchronous call is the more honest choice.
| Command | Event | Query | |
|---|---|---|---|
| Name | Imperative: ChargePayment | Past tense: OrderPlaced | Question: GetStockLevel |
| Number of receivers | Exactly one logical receiver | Zero or more | One |
| Can it be refused | Yes | No, it already happened | It can return an error |
| Contract owner | The receiver (Payments) | The publisher (Ordering) | The responder |
| Coupling | The sender knows the receiver | The publisher doesn't know the receivers | The caller knows the responder |
The contract owner row is the one people mix up most in practice. The shape of a command is decided by whoever will handle it: Payments knows what it needs to take a payment and publishes ChargePayment as its own contract. The shape of an event is decided by the publisher: Ordering decides what goes into OrderPlaced; instead of adding a separate field for every listening team, it describes the fact in its own language. In code, that shows up in the contracts' namespaces:
namespace Bookstore.Messaging;
public interface IMessage
{
// The contract's durable name: stays the same even if the CLR type name changes
static abstract string MessageType { get; }
}
public interface ICommand : IMessage;
public interface IEvent : IMessage;
public sealed record MessageEnvelope<T>(
Guid MessageId,
string MessageType,
Guid CorrelationId,
Guid? CausationId,
DateTimeOffset SentAt,
T Body) where T : IMessage
{
// The first message of a flow: the correlation id is its own id
public static MessageEnvelope<T> Start(T body, TimeProvider clock)
{
Guid id = Guid.CreateVersion7(clock.GetUtcNow());
return new(id, T.MessageType, id, null, clock.GetUtcNow(), body);
}
// A new message sent as a result of this one: same correlation, causation = this message
public MessageEnvelope<TNext> Next<TNext>(TNext body, TimeProvider clock) where TNext : IMessage =>
new(Guid.CreateVersion7(clock.GetUtcNow()), TNext.MessageType, CorrelationId, MessageId, clock.GetUtcNow(), body);
}
using Bookstore.Messaging;
namespace Bookstore.Ordering.Contracts
{
public sealed record OrderedBook(Guid BookId, string Title, int Quantity, decimal UnitPrice);
public sealed record ShippingAddress(string Name, string Street, string City, string PostalCode, string Country);
// Event: past tense, belongs to the publisher (Ordering), doesn't know its receivers
public sealed record OrderPlaced(
Guid OrderId,
Guid CustomerId,
IReadOnlyList<OrderedBook> Items,
decimal Total,
string Currency,
ShippingAddress ShipTo,
DateTimeOffset PlacedAt) : IEvent
{
public static string MessageType => "bookstore.ordering.order-placed.v1";
}
// Event notification: just "it happened" and an id
public sealed record OrderPlacedNotification(Guid OrderId) : IEvent
{
public static string MessageType => "bookstore.ordering.order-placed-notification.v1";
}
}
namespace Bookstore.Payments.Contracts
{
// Command: imperative, one logical receiver (Payments), the contract belongs to the receiver
public sealed record ChargePayment(Guid OrderId, decimal Amount, string Currency) : ICommand
{
public static string MessageType => "bookstore.payments.charge-payment.v1";
}
public sealed record PaymentCaptured(Guid OrderId, Guid PaymentId, decimal Amount, string Currency) : IEvent
{
public static string MessageType => "bookstore.payments.payment-captured.v1";
}
}
MessageType is defined as a static abstract interface member, which came with C# 11: each contract carries its own durable name, and generic code reaches it through T.MessageType without reflection. Why not typeof(T).Name? Because a CLR (Common Language Runtime) type name can change in a refactoring, while the name on the wire is a contract, and when it changes everyone listening breaks. The trailing .v1 is there for the same reason; contract evolution is the subject of the contracts and schema evolution post later in the series.
The Envelope: Why Three Ids?
MessageEnvelope<T> carries five pieces of information, and each answers a different question:
MessageId: the identity of this message. The key to noticing that the same message arrived twice; the idempotent consumer post of the series will use it as the dedup key.CorrelationId: which business flow does this message belong to? The same across every message in the flow.CausationId: which message gave birth to this one? It lets you build the tree of the flow.SentAt: when it was sent; for explaining inconsistencies and delays.MessageType: how to read the body; so the consumer knows which handler to call.
Let's see the difference between correlation and causation in a flow. An order was placed, a payment command was sent in response to that event, and a new event was published once the payment was captured:
using Bookstore.Messaging;
using Bookstore.Ordering.Contracts;
using Bookstore.Payments.Contracts;
using Bookstore.Shipping;
TimeProvider clock = TimeProvider.System;
OrderPlaced placed = new(
OrderId: Guid.CreateVersion7(),
CustomerId: Guid.CreateVersion7(),
Items: [new(Guid.CreateVersion7(), "Dune", 2, 30m), new(Guid.CreateVersion7(), "Emma", 1, 20m)],
Total: 80m,
Currency: "EUR",
ShipTo: new("Ada", "1 Harbour Street", "Izmir", "35000", "TR"),
PlacedAt: clock.GetUtcNow());
// One flow: OrderPlaced -> ChargePayment -> PaymentCaptured
MessageEnvelope<OrderPlaced> first = MessageEnvelope<OrderPlaced>.Start(placed, clock);
MessageEnvelope<ChargePayment> charge = first.Next(new ChargePayment(placed.OrderId, placed.Total, placed.Currency), clock);
MessageEnvelope<PaymentCaptured> captured = charge.Next(
new PaymentCaptured(placed.OrderId, Guid.CreateVersion7(), placed.Total, placed.Currency), clock);
static string Short(Guid? id) => id is { } g ? "…" + g.ToString("N")[^6..] : "-";
Console.WriteLine($"{"type",-40} {"id",-8} {"correlation",-12} causation");
Print(first.MessageType, first.MessageId, first.CorrelationId, first.CausationId);
Print(charge.MessageType, charge.MessageId, charge.CorrelationId, charge.CausationId);
Print(captured.MessageType, captured.MessageId, captured.CorrelationId, captured.CausationId);
void Print(string type, Guid id, Guid correlation, Guid? causation) =>
Console.WriteLine($"{type,-40} {Short(id),-8} {Short(correlation),-12} {Short(causation)}");
Console.WriteLine();
type id correlation causation
bookstore.ordering.order-placed.v1 …0ba94d …0ba94d -
bookstore.payments.charge-payment.v1 …451509 …0ba94d …0ba94d
bookstore.payments.payment-captured.v1 …0b37c1 …0ba94d …451509
All three messages share a correlation id: they are all the story of the same order. The causation id builds the chain: OrderPlaced gave birth to ChargePayment, and ChargePayment to PaymentCaptured. Filter the logs by correlation id and you see every piece of the flow; follow the causation id and you see who triggered whom. In the previous post we mentioned an id that would tie "checkout succeeded, the worker failed three minutes later" together; this is that id. Event stores such as EventStoreDB keep the same pair as the $correlationId and $causationId metadata fields. If you want to stick to one standard, the CloudEvents specification from the CNCF (Cloud Native Computing Foundation) defines a common envelope that requires the id, source, type, and specversion fields; the details are in the series' contracts post.
A note: a correlation id is not the same thing as a trace id in distributed tracing. A trace id is a technical trail and is new for every HTTP request; a correlation id is the identity of the business flow and doesn't change even if a saga runs for days. Carrying both with the message is the starting point for the observability topic in the last post of the series.
Event Notification or Event-Carried State Transfer?
In his 2017 article "What do you mean by Event-Driven?", Martin Fowler separates four different things that go by the name "event-driven": event notification, event-carried state transfer, event sourcing, and CQRS (Command Query Responsibility Segregation). The first two are directly about what we put inside an event.
With event notification, the event is thin: "an order was placed, here is its id". A receiver that wants details calls the publisher back. With event-carried state transfer, the event carries the data the receiver needs: the receiver keeps a copy on its side and doesn't ask the publisher. For its shipment draft, Shipping needs the recipient's name, the city, and the number of books. Let's write both approaches and run them while Ordering's API (Application Programming Interface) is down:
using Bookstore.Ordering.Contracts;
namespace Bookstore.Shipping;
public sealed record ShipmentDraft(Guid OrderId, string RecipientName, string City, int BookCount);
// Stands in for Ordering's HTTP API
public sealed class OrderingApiClient(Dictionary<Guid, OrderPlaced> orders)
{
public bool IsDown { get; set; }
public int Calls { get; private set; }
public Task<OrderPlaced> GetOrderAsync(Guid orderId)
{
Calls++;
if (IsDown) throw new HttpRequestException("Ordering API unavailable");
return Task.FromResult(orders[orderId]);
}
}
// Event notification: calls the publisher back for details
public sealed class ShippingOnNotification(OrderingApiClient ordering, List<ShipmentDraft> drafts)
{
public async Task HandleAsync(OrderPlacedNotification message)
{
OrderPlaced order = await ordering.GetOrderAsync(message.OrderId);
drafts.Add(new(order.OrderId, order.ShipTo.Name, order.ShipTo.City, order.Items.Sum(i => i.Quantity)));
}
}
// Event-carried state transfer: the data it needs is inside the event
public sealed class ShippingOnStateTransfer(List<ShipmentDraft> drafts)
{
public Task HandleAsync(OrderPlaced message)
{
drafts.Add(new(message.OrderId, message.ShipTo.Name, message.ShipTo.City, message.Items.Sum(i => i.Quantity)));
return Task.CompletedTask;
}
}
// Two Shipping consumers while the Ordering API is down
OrderingApiClient orderingApi = new(new() { [placed.OrderId] = placed }) { IsDown = true };
List<ShipmentDraft> fromNotification = [], fromState = [];
try
{
await new ShippingOnNotification(orderingApi, fromNotification).HandleAsync(new(placed.OrderId));
}
catch (HttpRequestException ex)
{
Console.WriteLine($"[notification] failed: {ex.Message} (API calls: {orderingApi.Calls})");
}
await new ShippingOnStateTransfer(fromState).HandleAsync(placed);
Console.WriteLine($"[state transfer] {fromState[0]}");
[notification] failed: Ordering API unavailable (API calls: 1)
[state transfer] ShipmentDraft { OrderId = 01a1b834-8ca6-72e7-a3ed-c09ca1d2bcc3, RecipientName = Ada, City = Izmir, BookCount = 3 }
The consumer working with notifications depends on Ordering at its very first step. The temporal coupling we tried to break in the previous post came back through the back door of the event: the event is asynchronous, but the call made to process it is synchronous. The load moves too; with 800 orders a day and three contexts listening to OrderPlaced (Inventory, Shipping, Notification), Ordering's API takes 2,400 extra calls a day. At the campaign peak, those calls land exactly when Ordering is busiest.
The consumer working with state transfer did its job even while Ordering was down. These are the benefits Fowler lists: resilience, lower latency, less load on the publisher. There is a price too: data gets copied and copies can go stale. If Ada changes her address after the order, Shipping's copy is out of date; Ordering has to publish a separate event for that (ShippingAddressChanged) and Shipping has to handle it too. And as the event grows, so does its contract: every field you put in is a promise that's hard to take back.
A practical rule: put into the event the data needed to describe the fact itself. For OrderPlaced, that's the order lines, the amount, the currency, and the delivery address, in other words the sentence "an order was placed" itself. The customer's loyalty points or the book's category tree are not part of that fact; whoever needs them gets them from their own source. Microsoft's idempotent consumer guidance points out a side benefit of state transfer too: when the event carries the absolute result, the consumer can apply it as an upsert, and the same event arriving twice does no harm.
Domain Events and Integration Events
The Domain-Driven Design series post "Domain Events: Raise, Collect, and Dispatch on SaveChanges" already had an OrderPlaced: a domain event raised inside the aggregate and dispatched to handlers in the same process. The Bookstore.Ordering.Contracts.OrderPlaced in this post, on the other hand, is an integration event: a versioned contract that leaves the process, goes to the broker, and is read by other bounded contexts. They may share a name, but they are two separate types.
The practical reason for the split is the rate of change. A domain event changes freely with the model: adding a field to OrderItem means fixing the handlers that use it with the compiler's help. An integration event, however, is deserialized by other teams in other repositories; the compiler can't see them. So writing the mapping from domain event to integration event explicitly (turning strongly typed ids into Guid, Money into an amount and a currency) is not a cost but a boundary. Where that mapping happens and how it gets published reliably together with the database commit is the subject of the series' outbox post.
Common Pitfalls
A command disguised as an event
SendOrderConfirmationEmailEvent says "Event" in its name, but its content is an order: it has one receiver and an expected outcome. Fowler calls this a "passive-aggressive command": the sender expects the receiver to do something but announces it as if it were a fact. If Shipping starts listening to this event, will it have to send emails? What if the Notification team wants to replace email with SMS (Short Message Service)? Publish the fact (OrderPlaced) and leave the reaction to the receiver; if you really are giving an order, send it openly as a command (SendOrderConfirmation).
Putting the whole entity into the event
OrderUpdated(Order Order) looks easy: whatever you need is in there. But every property of the Order class is now a contract. The payments team put CardLast4 there for debugging; now it goes to every context that listens, to every log, and to every dead-letter queue. And when the Ordering team turns ShippingCity into an Address value object, five teams' consumers break on the same day. Design the shape of the event independently of the entity.
CRUD events
OrderUpdated doesn't say what changed. Shipping has to keep the previous state of the order on every update and work out the difference itself: a cancellation, an address change, a payment? The intent is lost. Events from the business language such as OrderCancelled, ShippingAddressChanged, and PaymentCaptured let consumers listen only to the facts they care about. The rule from the Domain-Driven Design series post "Ubiquitous Language" applies here too: an event name should be something that can appear in a domain expert's sentence.
Publishing a command
Sending ChargePayment to a publish/subscribe topic opens the door to charging the money twice once a second subscriber is added later. A command is sent to a channel that goes to a single receiver; channel types are the subject of the next post.
A Small Experiment
★☆☆ Write down five message names from Bookstore (at least one from each context) and classify each as a command, an event, or a query. Who owns the contract for each?
★★☆ You want to add a CustomerEmail field to OrderPlaced because the Notification team asked for it. Do you see that as a natural part of event-carried state transfer, or as the publisher being shaped by a receiver? Where should Notification get the email address from?
★★★ Write a small "flow tree" printer for messages produced with Next: keep a few messages in a list, group them by correlation id, and print them as a tree indented by causation id. What does the tree look like if both OrderShipped and an email command follow PaymentCaptured?
We've covered what a message is and what its envelope carries. In the next post we'll look at the road that carries it: point-to-point queues, publish/subscribe topics, and competing consumers, and how the same concepts look different in RabbitMQ and Kafka.