- Engineering
10 Event-Driven Architecture Questions That Separate Architects from Framework Users
Publishing is the easy part. Staying correct when everything else fails is the architecture.
Event-driven architecture is easy to start.
Install Kafka. Add RabbitMQ. Configure SNS and SQS. Publish an event. Subscribe to it somewhere else.
Suddenly the architecture diagram looks sophisticated.
But using an event broker does not automatically mean you understand event-driven architecture.
The real complexity starts after the first event has been published. What happens when the same event arrives twice? What happens when events arrive in the wrong order? What happens when the database succeeds but publishing fails? What happens when one service fails halfway through a workflow involving five other services?
And perhaps the most important question: should this interaction have been event-driven in the first place?
Those are the questions that separate someone who knows an event-driven framework from someone who understands distributed systems. Here are ten questions I would use to evaluate whether an event-driven architecture is actually production-ready.
1. What happens if the same event is delivered twice?
One of the first assumptions that disappears in distributed systems is: “this message will only be processed once.”
In many production systems, you should assume the opposite. An event may be delivered more than once.
flowchart TD
A[Broker delivers PaymentCaptured] --> B[Consumer processes it]
B --> C{ACK reaches broker?}
C -->|Yes| D[Done]
C -->|No - consumer crashes| E[Broker redelivers]
E --> F[Same event processed again]
F --> G[Duplicate invoice?]
F --> H[Duplicate email?]
F --> I[Incorrect inventory movement?]
If the consumer blindly processes the event twice, you can end up with:
- duplicate payments
- duplicate invoices
- duplicate emails
- duplicated ledger entries
- incorrect inventory movements
That is why idempotency is a fundamental part of event-driven architecture. A consumer may store processed event IDs and check before processing:
flowchart TD
A[Event evt_98321 arrives] --> B{Already in processed_events?}
B -->|Yes| C[Ignore it]
B -->|No| D[Process the event]
D --> E[Record event ID]
E --> F[ACK to broker]
But idempotency can also exist at the business-operation level. “Set payment status for ORD-10042 to CAPTURED” is easier to make idempotent than “increase captured amount by $125.”
The architectural question is not “can duplicates happen?” — they can. The real question is: can the system remain correct when they do?
2. What happens when events arrive out of order?
Consider an order lifecycle:
stateDiagram-v2
[*] --> Created
Created --> Paid
Paid --> Packed
Packed --> Shipped
Shipped --> Delivered
Created --> Cancelled
Paid --> Cancelled
That sequence makes sense. But distributed systems do not always deliver events exactly how humans expect them to arrive. A consumer might observe the events in a different order entirely:
flowchart LR
subgraph Expected["Expected arrival"]
E1[OrderCreated] --> E2[OrderPaid]
E2 --> E3[OrderPacked]
E3 --> E4[OrderShipped]
end
subgraph Observed["What a consumer might observe"]
O1[OrderCreated] --> O2[OrderShipped]
O2 --> O3[OrderPaid]
end
Or worse — a cancelled order shipped:
flowchart LR
C1[OrderCancelled] --> C2[OrderShipped]
style C1 fill:#f8d7da,stroke:#dc3545
style C2 fill:#f8d7da,stroke:#dc3545
Developers sometimes respond with: “Kafka guarantees ordering.” That statement is incomplete. The correct question is: ordering within what boundary?
Kafka can preserve ordering within a partition. That does not mean your entire distributed system has universal global ordering. You therefore need to think about:
- partition keys
- aggregate boundaries
- sequence numbers
- entity versions
- stale event detection
- state-machine validation
An event might include an aggregate version:
{
"eventId": "evt-338",
"aggregateId": "order-1024",
"aggregateVersion": 17,
"type": "OrderShipped"
}
If the consumer has already processed version 18, version 17 may be stale and safely ignored. Another option is to model valid state transitions. If an event attempts to transition CANCELLED → SHIPPED, the domain model should reject it.
Message ordering is an infrastructure capability. Business ordering is a domain problem.
3. What happens if the database succeeds but publishing the event fails?
This is one of the most important failure scenarios in event-driven systems. Without any safeguards, this is what happens:
sequenceDiagram
participant App
participant DB as Database
participant Broker as Kafka / RabbitMQ
App->>DB: UPDATE orders SET status = 'PAID'
DB-->>App: Committed successfully
App->>Broker: Publish OrderPaid
Broker--xApp: Temporarily unavailable
Note over DB,Broker: Database says PAID.<br/>The rest of the system never hears about it.
The database says one thing; the rest of the system believes another. The consequences ripple outward:
- the inventory service never reserves stock
- the fulfillment workflow never starts
- the customer never receives confirmation
A common solution is the Transactional Outbox Pattern. Instead of updating the database and then publishing as separate steps, you perform both persistence operations inside the same database transaction:
sequenceDiagram
participant App
participant DB as Database
participant Outbox as Outbox Table
participant Publisher as Outbox Publisher
participant Broker as Kafka / RabbitMQ
App->>DB: BEGIN TRANSACTION
App->>DB: UPDATE orders SET status = 'PAID'
App->>Outbox: INSERT INTO outbox (event_type, payload)
App->>DB: COMMIT
Publisher->>Outbox: Poll pending records
Outbox-->>Publisher: OrderPaid
Publisher->>Broker: Publish OrderPaid
The important principle is: the business state change and the intent to publish must share the same consistency boundary. Without this, it is very easy to create invisible distributed data loss.
4. Is this really an event, or is a command disguised as one?
Naming messages correctly matters more than it appears. Consider SendInvoiceEmail and InvoiceGenerated. They are not the same thing.
SendInvoiceEmail expresses intent — someone is asking another component to perform an action. That is usually a command. InvoiceGenerated represents something that has already happened. That is an event.
flowchart LR
subgraph Commands["Commands - intent"]
C1[CapturePayment]
C2[GenerateInvoice]
C3[ApproveClaim]
C4[CancelOrder]
end
subgraph Events["Events - fact"]
E1[PaymentCaptured]
E2[InvoiceGenerated]
E3[ClaimApproved]
E4[OrderCancelled]
end
This distinction matters because commands and events create different coupling models.
flowchart TD
subgraph Command["Command - tight coupling"]
OS1[Order Service] -->|SendConfirmationEmail| NS1[Notification Service]
end
subgraph Event["Event - loose coupling"]
OS2[Order Service] -->|OrderConfirmed| BR[Broadcast]
BR --> ES[Email Service]
BR --> AS[Analytics Service]
end
With a command, the Order Service knows exactly what the Notification Service should do. With an event, the producer simply announces what happened and consumers independently decide whether they care.
A useful test: if nobody consumes this message, has the business fact still happened? If yes, it is probably an event. If the system expects someone to perform an action because of the message, you may actually be dealing with a command.
Calling everything an “event” does not make the system event-driven.
5. Who owns the event contract?
Events become APIs. And just like APIs, they evolve.
Suppose your original event looks like { "customerId": 1024, "name": "Yoosuf Mohamed" }. Six months later, someone wants to split it into firstName and lastName. That looks like a harmless refactor. It may not be.
There may already be a CRM service, marketing service, billing service, analytics service, notification service, and a data warehouse all consuming name. Changing an event schema can break systems you do not even deploy together:
flowchart TD
P[Producer] -->|"Event v1: {name}"| B[Broker]
B --> C1[CRM Service]
B --> C2[Billing Service]
B --> C3[Analytics Service]
B --> C4[Marketing Service]
Note1["Schema change breaks<br/>all consumers simultaneously"]
style Note1 fill:#fff3cd,stroke:#ffc107
Architects need to think about:
- backward compatibility
- forward compatibility
- schema evolution
- additive changes
- optional fields
- semantic versioning
- event ownership
- deprecation windows
- schema registries
- consumer contract testing
Instead of deleting fields immediately, you might temporarily evolve the event — include both name and firstName/lastName in version 2. Older consumers continue functioning. Newer consumers migrate. Eventually, the old field can be retired through an explicit compatibility process.
The real question is not “can the JSON still deserialize?” It is: can dozens of independently deployed consumers survive years of contract evolution?
6. What happens when a consumer keeps failing?
The event is retried. It fails again. Then again. What happens next?
Retrying forever is not a strategy. Retrying immediately can make things worse. If the downstream system is overloaded, sending more requests every few milliseconds can turn a small outage into a much larger incident.
flowchart TD
A[Message arrives] --> B[Consumer processes]
B -->|Success| C[ACK]
B -->|Failure| D[Retry with backoff]
D --> E{Retry limit reached?}
E -->|No| D
E -->|Yes| F[Dead Letter Queue]
F --> G{Operational response?}
G -->|Monitor and alert| H[Investigate]
G -->|Ignore| I[Business operations quietly disappear]
style I fill:#f8d7da,stroke:#dc3545
But adding a DLQ does not solve the operational problem. You must still answer:
- Who monitors it?
- When should alerts fire?
- Who investigates failures?
- How are messages corrected?
- How are messages replayed?
- What happens after replay?
- Can reprocessing cause duplicate side effects?
A dead-letter queue without operational ownership is simply a graveyard where business operations quietly disappear. Production EDA requires a failure strategy, not merely a failure destination.
7. How do you handle partial failure across multiple services?
Consider an e-commerce checkout. Now imagine: order created, payment captured, inventory reserve failed. What now?
You cannot usually wrap Order DB, Payment DB, Inventory DB, and Shipping DB inside one traditional ACID transaction. Instead, distributed systems often rely on Sagas — a sequence of compensating actions.
flowchart TD
A[OrderCreated] --> B[PaymentAuthorized]
B --> C[InventoryReservationFailed]
C --> D[PaymentAuthorizationCancelled]
D --> E[OrderCancelled]
style C fill:#f8d7da,stroke:#dc3545
style D fill:#fff3cd,stroke:#ffc107
style E fill:#f8d7da,stroke:#dc3545
The next architectural decision is whether the workflow uses choreography or orchestration.
Choreography
Each service reacts to events and emits another event. No central coordinator.
sequenceDiagram
participant Order
participant Payment
participant Inventory
participant Shipping
Order->>Inventory: OrderCreated
Inventory->>Payment: InventoryReserved
Payment->>Shipping: PaymentAuthorized
Shipping->>Order: ShipmentCreated
This can be elegant for simple flows. But as the process grows, it can become difficult to understand.
Orchestration
A workflow component coordinates the process.
flowchart TD
W[Checkout Workflow] --> P[Payment Service]
W --> I[Inventory Service]
W --> S[Shipping Service]
W --> N[Notification Service]
The orchestrator explicitly knows the workflow. This creates more centralized logic, but can significantly improve observability and control for complex business processes.
Neither approach is universally correct. Architects choose between them based on:
- workflow complexity
- team ownership
- compensation requirements
- visibility
- coupling
- auditability
- operational requirements
The important thing is that distributed workflows need explicit failure semantics.
8. What is the source of truth?
Once events enter an architecture, this question becomes critical. Where does truth live? Is it:
- PostgreSQL?
- Kafka?
- an Event Store?
- a materialized projection?
These are very different architectures.
In many event-driven applications, the database is the source of truth and events are notifications about changes. In Event Sourcing, events are the source of truth and current state is derived by replaying historical events.
flowchart LR
subgraph Traditional["Traditional - DB is the source of truth"]
DB1[(Orders Table)] -->|Change| EV1[OrderPaid event]
EV1 --> R1[Other services react]
end
subgraph Sourcing["Event Sourcing - events are the source of truth"]
E1[AccountOpened] --> E2[MoneyDeposited 1000]
E2 --> E3[MoneyWithdrawn 200]
E3 --> E4[MoneyDeposited 500]
E4 --> PR[Projection - Balance 1300]
end
This distinction is important: event-driven architecture does not automatically mean Event Sourcing. You can use Kafka without Event Sourcing. You can use RabbitMQ without CQRS. You can use events while PostgreSQL remains the authoritative source.
Event Sourcing can be extremely powerful for domains requiring complete audit history, temporal reconstruction, complex domain transitions, and historical state replay. But it also introduces significant complexity. A strong architect knows not only how to implement Event Sourcing — they know when not to use it.
9. How do you debug a business transaction across ten services?
In a synchronous application, debugging is relatively easy — one request, one trace, one response:
flowchart LR
REQ[HTTP Request] --> API[API] --> SRV[Service] --> DB[(Database)] --> RESP[Response]
An event-driven workflow may involve events across multiple services, several databases, different queues, multiple regions, and spans of minutes or hours. Now imagine customer support asks: “why was order 83492 never shipped?” Without proper observability, answering that question can become painful.
Events should carry enough metadata to reconstruct causality:
sequenceDiagram
participant O as Order Service
participant P as Payment Service
participant I as Inventory Service
participant S as Shipping Service
participant N as Notification Service
Note over O,N: correlationId checkout-772
O->>P: OrderCreated (evt-975)
P->>I: PaymentAuthorized (evt-978)
I->>S: InventoryReserved (evt-981)
S--xN: ShipmentCreationFailed (evt-985)
Note over S,N: causationId evt-981 / traceId 01HXYZ
Now you can reconstruct the whole chain — correlation IDs, causation IDs, trace IDs, event IDs. A mature platform should think about:
- distributed tracing
- structured logs
- correlation IDs
- event IDs and causation IDs
- metrics and consumer lag
- failed-message dashboards
- workflow state inspection
Observability is not something you bolt onto an event-driven architecture later. It is part of the architecture itself.
10. Should this interaction be event-driven at all?
This may be the most important question on the list. Engineers sometimes discover Kafka and suddenly every interaction becomes an event. That is usually a warning sign.
Imagine you need customer details. This may be completely reasonable:
GET /customers/123
You probably do not need this — a request, a broker round-trip, a response, another broker round-trip:
flowchart TD
C[CustomerRequested] --> K1[Kafka]
K1 --> L[CustomerLoaded]
L --> K2[Kafka]
K2 --> R[Response]
style K1 fill:#f8d7da,stroke:#dc3545
style K2 fill:#f8d7da,stroke:#dc3545
flowchart TD
REQ[Incoming request] --> Q{Does the caller need<br/>an immediate response?}
Q -->|Yes| SYNC[Use synchronous - REST / gRPC / GraphQL]
Q -->|No| Q2{Is this a workflow,<br/>fan-out, or integration?}
Q2 -->|Yes| ASYNC[Use asynchronous - Events / Queues]
Q2 -->|No| SYNC
style SYNC fill:#d4edda,stroke:#28a745
style ASYNC fill:#cce5ff,stroke:#004085
Event-driven communication makes sense when you need:
- asynchronous processing
- temporal decoupling
- fan-out to independent consumers
- buffering and scalable processing
- workflow propagation
- integration events
- eventual consistency
Synchronous communication is often better when:
- the caller needs an immediate response
- the interaction is request-response
- strong consistency is required
- failure must be returned immediately
- there is no meaningful reason to decouple the participants
Many mature architectures intentionally use both. The architectural skill is not knowing how to introduce Kafka — it is knowing where Kafka does not belong.
The architect-level test
If I were interviewing for a system using event-driven architecture, I would ask questions like these:
- How do you guarantee idempotent consumption?
- How do you handle duplicate and out-of-order events?
- How do you guarantee consistency between database changes and event publication?
- Is this message an event, command, or query?
- How will event contracts evolve without breaking existing consumers?
- What is the retry, backoff, poison-message, and DLQ strategy?
- How does the system recover from partial failure across multiple services?
- What is the authoritative source of truth?
- How do you trace and debug an asynchronous workflow across services?
- Why should this interaction be event-driven in the first place?
If most of the answers involve tool names — Kafka, RabbitMQ, MassTransit, Spring Boot, NestJS, MediatR, NServiceBus, AWS EventBridge — then the discussion is still mostly about tools. Those tools are useful. But architecture happens one level above them.
The stronger answers start talking about:
- idempotency and delivery semantics
- consistency boundaries
- aggregate ordering
- schema evolution
- failure domains
- temporal coupling
- compensation
- observability
- replay and recovery
That is where event-driven architecture becomes a distributed-systems problem rather than a framework configuration exercise.
And there is an 11th question
There is one question I would add for systems handling money, emails, notifications, inventory, claims, or other external side effects: how would you safely replay six months of events without replaying six months of real-world side effects?
flowchart TD
R[Replay historical events] --> Q{Events trigger side effects?}
Q -->|Yes| DANGER[50,000 emails resent<br/>10,000 payment requests rerun<br/>Inventory deducted twice]
Q -->|No| SAFE[State rebuilt cleanly]
DANGER --> NEED[Need to distinguish]
NEED --> A[Rebuilding state]
NEED --> B[Re-executing side effects]
style DANGER fill:#f8d7da,stroke:#dc3545
style SAFE fill:#d4edda,stroke:#28a745
That distinction affects:
- event replay
- projections
- idempotency
- integration boundaries
- consumer design
- migration strategies
- disaster recovery
If a team cannot explain how replay works safely, the event-driven architecture probably is not as mature as the architecture diagram makes it look.
Final thought
Event-driven architecture is not difficult because publishing an event is difficult. Publishing is the easy part.
The difficult part is accepting that once services communicate asynchronously, many assumptions from a traditional application disappear. Messages can be duplicated. Messages can arrive late. Consumers can fail. Services can disagree temporarily. Schemas evolve independently. Workflows can partially complete. Networks disappear. And eventually someone will need to understand exactly what happened to one customer transaction three months ago.
That is why understanding event-driven architecture requires more than understanding Kafka, RabbitMQ, or a framework abstraction.
Frameworks teach you how to publish events. Architecture teaches you how to remain correct when everything around those events starts failing.