TT Lab
Get started
Learn Learning paths Courses

Microservice Architecture

Send a Fact, Not a Command

Continue in TT Lab

Summary

A synchronous call is "do this for me", and an event is "this happened". The latter does not need the other side to be alive right now.

Why this was needed

Suppose that when an order is created, you must reduce inventory, accrue points, send a notification, and record an analytics event. If you chain these with synchronous calls, the order API's response time becomes the sum of the four services' response times, and its availability becomes the product of the four services' availabilities. If each is 99.9%, the combined is 99.6%. And if the notification service dies, you cannot take orders. It is a design where revenue stops because of notifications.

If you switch to events, the order service publishes just one fact, "order created", and is done. The other four consume it at their own pace. Even if the notification service is down for 10 minutes, orders keep being accepted, and when it comes back it processes the backlog of events.

How it works

What matters here is the name. SendNotification is a command and OrderCreated is a fact. If you send a command to a queue, it is just an asynchronous RPC. The sender knows the receiver, and if receivers increase, the sender's code changes. If you publish a fact, the sender does not know who is listening. Even if a new consumer attaches, the publisher stays the same. This is the point where coupling is actually reduced.

The event payload should carry enough information for the consumer to complete its work. If the consumer goes back and queries the publisher's DB, the coupling comes back to life. However, if you put in too much, the event becomes a schema, and a schema is a contract and becomes hard to change. The practical compromise is to put in the identifier plus only the core fields that will not change.

The cost is also clear. Eventual consistency. A time window arises in which the order was created but the inventory may not have decreased yet. Whether that window is 200ms or 30 seconds, and what the user will see in between, must be decided at the product level. The on-screen phrase "it will be reflected shortly" is actually an architecture decision.

The event name decides the design

Even if they carry the same content, the system differs depending on the name. A table makes it clear.

Name Kind What the sender knows If consumers increase
SendNotification Command It knows who will handle it The sender's code changes
ReserveStock Command It knows who will handle it The sender's code changes
OrderCreated Fact It knows nobody The sender stays the same
PaymentApproved Fact It knows nobody The sender stays the same

If you use a past-tense verb, it automatically becomes a fact. One naming rule protects the architecture.

There are also places where a command is needed. It is when "do this now" must go to exactly one consumer. In that case you use a queue (point to point), and you send facts to a topic (publish-subscribe). If you mix the two in the same channel, the distinction soon disappears.

Where publishing fails

The most dangerous moment is when the DB commits but the event publication fails. The order was created but nobody knows. Conversely, if you send the event first and the DB fails, events for orders that do not exist go around.

❌ 위험한 순서
   BEGIN; INSERT order; COMMIT;
   publish(OrderCreated)      ← 여기서 죽으면 이벤트가 영영 안 나간다

✅ 아웃박스 패턴
   BEGIN;
     INSERT order;
     INSERT outbox(topic, payload);   ← 같은 트랜잭션 안
   COMMIT;
   (별도 프로세스가 outbox 를 읽어 발행하고 표시한다)

Because both are committed within a single transaction, the state "the order exists but the event does not" becomes fundamentally impossible. In exchange, publication is slightly delayed, and the same event can go out twice (if it dies after publishing and before marking). That is why the consumer must always be idempotent.

A schema is a contract

The moment you publish an event, it becomes a public API. You do not know who is listening, so you cannot change it carelessly. Two rules get you through most cases.

The consumer side has a rule too. Ignore unknown fields. If you use strict deserialization (additionalProperties: false), every time the publisher adds one field, all the consumers break.

What you meet in the field

Switching to asynchronous makes debugging harder. A synchronous call ends with a single stack trace, but for events the publication time and the consumption time are apart. That is why you must put a correlation ID in every event. Operating an event architecture without it is like driving with your eyes closed.

One more thing. Event order may not be guaranteed. If OrderCreated and OrderPaid for the same order arrive reversed, the consumer breaks. If you set the partition key to the aggregate ID (for example, the order ID), order is guaranteed within the same aggregate. Usually you give up global ordering.

What to check in the next quiz

This module covers only concepts. The problem of actually publishing events safely is handled by hand in the outbox lab of the very next module.