What is it? #
In an event-driven architecture, components publish events describing what happened, and other components react.
An event is a statement of fact in the past tense: OrderPlaced, PaymentFailed, UserRegistered. It is not an instruction. The publisher does not know or care who consumes it.
That inversion is the point. Adding a new reaction means adding a consumer, with no change to the publisher.
The cost is that the flow of a business process is no longer visible in any single place, and consistency becomes eventual rather than immediate.
Think of it like this #
A public announcement board rather than individual phone calls. The dispatcher posts "order 123 shipped" and whoever cares — billing, the customer app, the analytics team — reads it.
Nobody has to maintain a list of who to phone. The trade is that no single person can tell you everything that happens after an order ships.
Simple example #
When an order is placed, four things must follow: a confirmation email, a warehouse notification, an analytics event and a loyalty update. Direct calls would couple ordering to all four.
Code #
# Events are facts, named in the past tense, with a stable shape
ORDER_PLACED = {
"event": "order.placed",
"version": 1, # version the schema from day one
"id": "evt_01H9...", # unique, for deduplication
"occurred_at": "2026-09-22T10:15:03Z",
"data": {
"order_id": "A-1",
"customer_id": 7,
"total": 2499.0,
"currency": "INR",
},
}
# Publisher: writes the event in the SAME transaction as the state change
def place_order(cart, user):
with db.transaction():
order = orders.insert(user_id=user.id, lines=cart.lines, total=cart.total)
outbox.insert( # transactional outbox
event="order.placed",
payload={"order_id": order.id, "customer_id": user.id, "total": order.total},
)
return order
# A separate process reads the outbox and publishes to the broker.
# This guarantees the event exists if and only if the order does.
# Consumer: independent, idempotent, and free to fail without affecting others
def on_order_placed(event):
if processed.seen(event["id"]): # deduplicate
return
send_confirmation_email(event["data"]["order_id"])
processed.record(event["id"])
Events vs commands
command "ChargePayment" an instruction to one specific handler, may be rejected
event "PaymentCharged" a fact that already happened, for anyone interested
Mixing them up produces events that are really instructions, which
recreates the coupling you were trying to remove.
What this costs you
flow visibility no single file shows what happens after an order
eventual consistency consumers lag; the read model may be briefly stale
ordering events may arrive out of order across partitions
duplicates at-least-once delivery means consumers must be idempotent
schema evolution old consumers must tolerate new fields; version events
debugging you need tracing and an event log to reconstruct a flow
How it works #
Events describe facts, which is why the past tense matters. OrderPlaced cannot be refused; PlaceOrder can. Naming them as facts keeps the publisher free of assumptions about consumers.
The transactional outbox solves the hardest reliability problem in this style. Writing to the database and publishing to a broker are two systems, so one can succeed while the other fails. Writing the event into an outbox table inside the same transaction makes them atomic, and a separate process publishes from the outbox afterwards.
Consumers must be idempotent, because brokers deliver at least once. Recording processed event IDs is the standard defence.
Consumers are independent. If the loyalty consumer is broken, emails still go out, and the loyalty events can be replayed once it is fixed.
Versioning events from the first day is worth the small effort. Consumers deploy at different times, so an event shape change without versioning breaks whichever consumer updates last.
Event sourcing is a further step, where the event log becomes the source of truth and current state is derived by replaying it. It gives a complete audit trail and time travel, at the cost of significant complexity — it is a separate decision from simply using events.
Real-world use #
Kafka, Pulsar, cloud event buses and durable queues are the usual infrastructure. Kafka in particular keeps a retained log, so consumers can replay history — which is what makes adding a new consumer over old data possible.
Analytics and data pipelines are the most common entry point: publishing domain events and letting a warehouse consume them avoids querying production databases for reporting.
Eventual consistency has to be designed into the product, not hidden. A user who places an order and sees "processing" rather than an instant confirmation is fine; one who sees their order missing is not.
The debugging burden is real. Without distributed tracing and a searchable event log, reconstructing why something did not happen becomes archaeology.
Many systems use events selectively: synchronous calls for the essential path, events for the follow-ups. That combination keeps the critical flow visible while decoupling everything optional.
Common mistakes #
- Publishing an event after the transaction commits, so a crash loses it — use an outbox.
- Events that are really commands, which recreates tight coupling.
- Consumers that are not idempotent, so duplicates cause double effects.
- No versioning, so a schema change breaks consumers that deploy later.
- Using events for a flow that needs an immediate, consistent answer.
Practice #
Design the events for a subscription service: signup, payment succeeded, payment failed, subscription cancelled. Specify the payload and version for each, name two consumers per event, and describe how you would guarantee the event is published exactly when the state change happens.