Split a system into services and the first question is always the same: how do they talk? The obvious answer - service A calls service B over HTTP - is also the one that quietly undoes most of the reason you split things up in the first place.
Every direct call is a hard dependency. A needs B to be up, fast, and on the version it expects. Chain a few of those together and you've rebuilt the monolith you were trying to escape - except now the function calls cross the network, where they can time out, retry, and cascade. It looks like microservices on the architecture diagram. It fails like a monolith in production.
A direct call says "I need you, right now." An event says "this happened - do what you want with it." Only one of those scales to a team you'll never meet.
Publish facts. Don't orchestrate.
The default I reach for now: when something meaningful happens in a service, it emits a domain event - a statement of fact about the past - and moves on. It doesn't know or care who's listening. Other services subscribe to the events they care about and react on their own schedule.
Concretely, on the estate I work on, a write looks like this:
# write side command → update DynamoDB # source of truth → Streams emit change → EventBridge domain event # "ProductChanged" # anyone interested reacts, independently search-indexer → update OpenSearch # read model partner-push → orchestrator → worker fan-out audit → append-only log
The service that changed the product never called the search indexer, the partner-push pipeline, or the audit log. It published one fact. Three unrelated capabilities reacted. Tomorrow a fourth subscriber can appear without the producer changing a single line.
Fan-out is the superpower
This is where event-driven earns its keep. A config change in one service fans out to every product that depends on it, as events - not as a fragile chain of synchronous calls the producer has to know about. For heavy work like pushing updates to external partners, an orchestrator receives the event and emits per-item work events that a pool of workers handle in parallel. Load levels itself. Failures retry in isolation instead of taking down the request that triggered them.
The honest trade-offs
Events are not free, and anyone who sells them as a pure win hasn't run them. What you're really buying:
- Eventual consistency. The read model lags the write by milliseconds-to-seconds. You have to design the UX for it - and explain it to stakeholders who expect "save" to mean "instantly everywhere."
- Harder debugging. There's no stack trace across an event boundary. "Why did this happen?" becomes a correlation exercise. You pay this back with correlation IDs and real observability, or you suffer.
- The contract moves into the event shape. With no central API to lean on, the event payload is the contract. Without versioning discipline and ideally a schema registry, a producer's innocent change silently breaks a subscriber it's never heard of.
- Idempotency and ordering are your problem. Events get delivered at-least-once and sometimes out of order. Handlers must be safe to run twice.
What I'd tell you to steal
- Make events the default, calls the exception. Use a direct call only when you genuinely need a synchronous answer right now.
- Emit facts about the past, not commands for the future. "ProductChanged," not "UpdateSearchIndex." Producers shouldn't know their consumers.
- Treat the event payload as a versioned contract from day one - a registry beats archaeology.
- Build correlation and idempotency in early. They're miserable to retrofit.
Event-driven architecture isn't about message brokers or any particular cloud service. It's a stance: services should depend on facts, not on each other. Get that right and the estate stays loosely coupled as it grows. Get it wrong and you've just made your monolith harder to debug.
Designing a service estate?
I architect distributed systems and AI platforms that stay loosely coupled as they scale. Happy to trade notes.
Get in touch →