Why an order should not call five services
A customer confirms an order and five things must happen: decrement the stock, send the confirmation email, notify shipping, credit the loyalty points, update the statistics. If the Orders service calls them one after the other, synchronously, a slow email is enough to make the customer wait, and a loyalty service that is down is enough to make the order fail for a reason the customer does not care about. Every new feature is also a change to the Orders service.
In an event-driven architecture the Orders service does one thing only: it records the order and publishes a fact,
OrderPlaced. Whoever needs to react subscribes and receives it on their own, at their own pace. The gain is
decoupling: the publisher does not know the consumers and does not wait for their reply. The cost is that the reply comes
later, asynchronously, and that the system becomes consistent “over time” instead of immediately.
This article opens the series and fixes the vocabulary: what an event is, how it differs from a command, how it is delivered to several recipients, how it is described and how it is changed without breaking anyone. The experiments are small in-memory programs, because there is no broker here: where a real broker behaves differently, I say so.
Event, command, query: three different messages
The difference shows in the verb and in the recipient. An event tells something that has already happened
(OrderPlaced) and cannot be refused, because the fact has occurred. A command asks someone to do
something (SendEmail) and can be rejected or fail. A query asks for data and waits for an answer;
in an asynchronous system it needs a reply queue, and it is the case where asynchrony costs the most.
| Type | What it says | Recipients | Can it fail? | Example |
|---|---|---|---|---|
| Event | A past fact | From zero to N, unknown to the sender | The fact is not disputed, but whoever processes it can fail | OrderPlaced |
| Command | A request for action | Exactly one, known to the sender | Yes, and the sender cares | SendEmail |
| Query | A request for data | One, with a reply | Yes | GetOrderStatus |
The name is the quickest test. If you can write the message in the past tense, it is an event; if it is an imperative, it is a
command. Getting the category wrong has concrete consequences: a SendEmail published as if it were an event goes
to whoever is subscribed, and nobody is responsible for actually sending the email. An event sent as a command ties the sender
to a recipient it has no need to know.
Martin Fowler also distinguishes two styles of event: the notification, which carries the minimum (the identifier) and forces the consumer to request the rest, and event-carried state transfer, which carries the necessary data. The second avoids the return requests but copies data everywhere and lets it go stale; the choice is a compromise, not a rule.
What makes a good domain event
- Immutable: it describes the past and is not modified after publication.
- Named in the past tense:
OrderPlaced, notPlaceOrder. - Self-sufficient enough: it carries the data that typical consumers require.
- Typed: for each type there is a known, versioned schema.
The four properties do not weigh the same. Immutability is the most important, because it is what allows you to replay the history and to correct an error with a new event instead of rewriting the old one; the past-tense name is its linguistic consequence. Self-sufficiency is the most debatable, as seen above: it has to be decided consumer by consumer, looking at what would happen if the service owning the data were unreachable at the moment of reading.
Publish to many, send to one: an in-memory experiment
The mechanism that separates event from command boils down to a question about queues. When you publish an event, the broker puts a copy in the queue of every subscriber; when you send a command, there is a single queue and the workers compete for it, so that each message reaches only one of them. The program below implements both in a thirty-line class. It is not a replacement for a broker: it does not write to disk, and has no acknowledgements or retries. It serves to make the two semantics visible, along with an effect that matters in production, the queue that builds up when a consumer is stopped.
interface DomainEvent {
eventId: string;
eventType: string;
occurredAt: string;
aggregateId: string;
version: number;
payload: Record<string, unknown>;
}
let seq = 0;
function orderPlaced(orderId: string, total: number): DomainEvent {
return {
eventId: `evt-${++seq}`,
eventType: 'OrderPlaced',
occurredAt: '2026-11-12T10:00:00Z',
aggregateId: orderId,
version: 1,
payload: { orderId, total, currency: 'EUR' },
};
}
class Broker {
private queues = new Map<string, DomainEvent[]>();
private subscribers = new Map<string, string[]>();
// An event has N recipients: each subscribes under its own name and gets its own queue.
subscribe(eventType: string, consumer: string): void {
this.queues.set(consumer, this.queues.get(consumer) ?? []);
this.subscribers.set(eventType, [...(this.subscribers.get(eventType) ?? []), consumer]);
}
// The publisher knows nobody: the broker copies the event into every subscribed queue.
publish(event: DomainEvent): void {
for (const consumer of this.subscribers.get(event.eventType) ?? []) {
this.queues.get(consumer)!.push(event);
}
}
// A command has a single recipient: one queue shared by all the workers.
send(queue: string, message: DomainEvent): void {
this.queues.set(queue, [...(this.queues.get(queue) ?? []), message]);
}
poll(queue: string): DomainEvent | undefined {
return this.queues.get(queue)?.shift();
}
backlog(queue: string): number {
return this.queues.get(queue)?.length ?? 0;
}
}
const broker = new Broker();
for (const c of ['inventory', 'email', 'loyalty']) broker.subscribe('OrderPlaced', c);
broker.publish(orderPlaced('ord-1', 150));
broker.publish(orderPlaced('ord-2', 80));
// The email service is down: the other two consume their own messages.
for (const c of ['inventory', 'loyalty']) {
for (let e = broker.poll(c); e; e = broker.poll(c)) console.log(`${c} processes ${e.eventId} (${e.aggregateId})`);
}
console.log('email queue waiting:', broker.backlog('email'));
// The email service restarts and catches up on everything it had missed.
for (let e = broker.poll('email'); e; e = broker.poll('email')) console.log(`email processes ${e.eventId} (${e.aggregateId})`);
// Command: three sends, two workers on the same queue. Each message goes to one only.
for (let i = 1; i <= 3; i++) broker.send('cmd:SendEmail', orderPlaced(`ord-${i}`, 10));
const workers = ['worker-A', 'worker-B', 'worker-A'];
for (const w of workers) console.log(`${w} takes`, broker.poll('cmd:SendEmail')?.aggregateId);
// Output:
// inventory processes evt-1 (ord-1)
// inventory processes evt-2 (ord-2)
// loyalty processes evt-1 (ord-1)
// loyalty processes evt-2 (ord-2)
// email queue waiting: 2
// email processes evt-1 (ord-1)
// email processes evt-2 (ord-2)
// worker-A takes ord-1
// worker-B takes ord-2
// worker-A takes ord-3
In the first part subscribe creates a queue for each consumer and publish copies the event into all
of them: inventory and loyalty work immediately, while email, which does not run
poll, accumulates two messages without the publisher noticing or slowing down. When the service restarts it finds
them all, in the order in which they were published.
In the second part send puts three commands in the same queue and two workers take them in
turn: ord-1 and ord-3 go to A, ord-2 to B, no message to both. For brevity the command
reuses the shape of the event. On a real broker this behaviour is the documented semantics of a queue with several
consumers, but there are differences: the assignment to workers is not a strict rotation, and an unacknowledged message goes
back to the queue and may be delivered again (this is covered in the article on idempotency).
Queue, bus and broker: three words for different things
In everyday language message queue, event bus and message bus are used interchangeably. It is worth keeping them distinct. A queue delivers each message to one consumer among those connected, and is the tool for commands and for work to be divided up. A bus (or topic) delivers the same message to all subscribers, and is the tool for events.
A broker is the software that hosts both: RabbitMQ calls them exchange and queue, Kafka uses topic and consumer group. The model I just simulated, a topic with one queue per subscriber, is the fan-out scheme that many managed services offer and that the article dedicated to SQS, SNS and EventBridge compares.
CloudEvents: a common envelope for events
If every team invents its own envelope, every consumer writes a different parser for every producer. CloudEvents is a
specification that fixes the common attributes of the envelope and leaves the content free. In version 1.0.2 the required
attributes are four: id, source, specversion and type; all the others
(subject, time, datacontenttype, dataschema) are optional. The project has
grown to “Graduated” status in the CNCF, announced on 25 January 2024 on the project website.
{
"specversion": "1.0",
"id": "ord-789-placed-1",
"type": "com.example.order.placed",
"source": "/orders-service",
"subject": "order-789",
"time": "2026-11-12T10:30:00Z",
"datacontenttype": "application/json",
"dataschema": "https://schemas.example.com/order/placed/v1.json",
"data": { "orderId": "order-789", "customerId": "cust-123", "totalAmount": 150.00, "currency": "EUR" }
}
The fields that matter for your system are id and source. The specification says that the pair
source + id must be unique for each distinct event and that a consumer may treat
two events with the same pair as duplicates: this is the basis on which deduplication is built. The type should
have a prefix in reverse DNS notation (com.example.order.placed), so that the domain indicates who defines the
meaning of the event. The JSON above is not executable (it is a message, not a program) and was written following the
specification; the address schemas.example.com is a placeholder domain reserved for examples.
Changing a schema without breaking the consumers
An event has many readers that you do not choose and that do not release together with you. A schema change that looks trivial in one service breaks another that nobody remembered. The practical rule is to distinguish additive changes (new optional fields) from those that change the meaning of an existing field (renaming it, changing its type, moving it). The former are compatible with a tolerant reader, which ignores what it does not know; the latter are not. The program below shows the two cases with a consumer written for version 1.
type Payload = Record<string, unknown>;
// Consumer written for v1: reads only orderId, customerId, totalAmount.
function consumerV1(p: Payload): string {
const { orderId, customerId, totalAmount } = p as { orderId: string; customerId: string; totalAmount: number };
if (typeof totalAmount !== 'number') throw new Error(`totalAmount missing or not numeric in ${orderId}`);
return `${orderId} from ${customerId}: ${totalAmount.toFixed(2)}`;
}
const v1 = { orderId: 'ord-9', customerId: 'cli-3', totalAmount: 150 };
// Compatible change: v2 ADDS an optional field. The v1 consumer does not notice.
const v2Additive = { ...v1, estimatedDeliveryDate: '2026-11-20' };
console.log('additive :', consumerV1(v2Additive));
// Incompatible change: v2 RENAMES totalAmount to total and moves it into an object.
const v2Broken = { orderId: 'ord-9', customerId: 'cli-3', total: { amount: 150, currency: 'EUR' } };
try {
console.log('renamed :', consumerV1(v2Broken));
} catch (e) {
console.log('renamed : ERROR ->', (e as Error).message);
}
// Output:
// additive : ord-9 from cli-3: 150.00
// renamed : ERROR -> totalAmount missing or not numeric in ord-9
The consumer extracts only three fields and ignores the rest: this is why the additive version, with
estimatedDeliveryDate, passes without changes. In the broken version the totalAmount field no longer
exists; the typeof totalAmount !== 'number' check turns it into a clear error, whereas without it the consumer
would have printed NaN without stopping. This is the reason why an incompatible change is published as a new
version of the type (OrderPlaced.v2) and both are kept alive until all the readers have migrated: the CloudEvents
specification itself gives the type com.example.object.deleted.v2 as an example.
When it pays off and when it does not
Event-driven architecture makes adding consumers cheap, because the publisher does not change, and isolates failures, because
a stopped consumer does not block the others, as seen with the email queue. It also gives you a trace of what has
happened. It costs in other ways, and they are worth listing before adopting it:
- the user does not get the result in the same response: you need a way to show an intermediate state;
- an operation that must be atomic across several services requires a saga, not an event;
- debugging goes through correlation identifiers and distributed traces, not through a stack trace;
- a monolith with few features does not repay a broker to manage.
These are four different costs: the first two are about product and design, the third about operations, the fourth about proportion. The most reliable signal for adopting events is not fashion, but the question “how many times have I had to touch the Orders service just to make another service react?”. If the answer is never, a synchronous call is more than enough.
What you can do now
You can tell whether a message is an event, a command or a query from its verb and its recipient, and you know why an event goes to many queues and a command to one. You can describe an event with the required CloudEvents attributes and tell a harmless schema change from one that breaks the consumers. The next steps: Event Sourcing uses events as the source of truth, CQRS separates reads and writes, the saga coordinates operations across several services. If you need the broker underneath, the series on Apache Kafka describes a concrete one. When events become the source of truth you move on to the union of event sourcing and CQRS; for a managed bus with content-based rules there is EventBridge; to publish an event without losing it or inventing it there is the outbox; for what the user sees in the meantime, eventual consistency.
Sources
- CloudEvents Specification v1.0.2 - required attributes, uniqueness of
source+id, reverse DNS prefix oftype, consulted on 2026-11-12 - CloudEvents, project website - Graduated status in the CNCF since 25 January 2024, consulted on 2026-11-12
- Martin Fowler, What do you mean by “Event-Driven”? - the four meanings of the term, including event notification and event-carried state transfer, consulted on 2026-11-12
The two programs in the article were run with Node.js v24.11.0 (which runs .ts files by simply stripping the types): the outputs in the comments are the real ones. They are in-memory simulations, not a broker.
Commenti
Caricamento commenti...
Sign in to comment