Synchronous HTTP calls create tight coupling. Service A waits for Service B, which waits for Service C. One slow service blocks everything. One failure cascades everywhere.

Event-driven architecture breaks this chain.

The Core Idea

Instead of direct calls, services communicate through events:

TOEOISErrvrnhmadedviadeneepiirtrnplt-tiiSdSonSoerergenriryravvvSvlieiSeicncerc(eerves(viyaicnscecHwypehTanurTicboPthlnrisssoosuuuunhbbbsIoesss)nusccc:vsrrre)"iiin:ObbbtreeeodsssreyrCSr"""eeOOOrarrrvtdddieeeecdrrre"CCCrrreeeaaaHwMtttTaeeeeTisdddPts"""ageShBirpopkienrgService

The Order Service doesn’t know or care who’s listening. It just announces what happened.

Events vs Commands

Events: Past-tense facts. “OrderCreated”, “PaymentReceived”, “UserRegistered”

  • Publisher doesn’t expect specific action
  • Multiple subscribers can react differently
  • Cannot be rejected (already happened)

Commands: Imperative requests. “CreateOrder”, “ProcessPayment”, “SendEmail”

  • Sender expects specific action
  • Usually one handler
  • Can fail or be rejected
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
# Event: announcing what happened
event = {
    "type": "OrderCreated",
    "timestamp": "2026-03-04T12:00:00Z",
    "data": {
        "order_id": "abc-123",
        "customer_id": "user-456",
        "total": 99.99
    }
}

# Command: requesting action
command = {
    "type": "SendOrderConfirmation",
    "data": {
        "order_id": "abc-123",
        "email": "customer@example.com"
    }
}

Message Brokers

RabbitMQ

Good for: Task queues, routing complexity, request-reply patterns

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import pika

# Publisher
connection = pika.BlockingConnection(pika.ConnectionParameters('localhost'))
channel = connection.channel()

channel.exchange_declare(exchange='orders', exchange_type='topic')
channel.basic_publish(
    exchange='orders',
    routing_key='order.created',
    body=json.dumps(event)
)

# Consumer
def callback(ch, method, properties, body):
    event = json.loads(body)
    process_order(event)
    ch.basic_ack(delivery_tag=method.delivery_tag)

channel.queue_declare(queue='inventory-updates')
channel.queue_bind(queue='inventory-updates', exchange='orders', routing_key='order.*')
channel.basic_consume(queue='inventory-updates', on_message_callback=callback)
channel.start_consuming()

Apache Kafka

Good for: High throughput, event replay, stream processing

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
from kafka import KafkaProducer, KafkaConsumer

# Publisher
producer = KafkaProducer(
    bootstrap_servers=['localhost:9092'],
    value_serializer=lambda v: json.dumps(v).encode('utf-8')
)

producer.send('orders', value=event, key=b'abc-123')
producer.flush()

# Consumer
consumer = KafkaConsumer(
    'orders',
    bootstrap_servers=['localhost:9092'],
    group_id='inventory-service',
    auto_offset_reset='earliest',
    value_deserializer=lambda m: json.loads(m.decode('utf-8'))
)

for message in consumer:
    process_order(message.value)

Redis Streams

Good for: Simple use cases, already using Redis, lower latency needs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
import redis

r = redis.Redis()

# Publisher
r.xadd('orders', {'event': json.dumps(event)})

# Consumer
last_id = '0'
while True:
    messages = r.xread({'orders': last_id}, block=5000)
    for stream, entries in messages:
        for entry_id, data in entries:
            process_order(json.loads(data[b'event']))
            last_id = entry_id

Common Patterns

Publish-Subscribe (Fan-out)

One event, multiple independent consumers:

OrderCreatedIEAFnmnrvaaaeilunlydttoSiSreceyrsrvvSiSiececrerevvi(i(cscceeehne(d(crtkecrsoafencorfkrviermamensatottrmoiiacoclknsi)))es)

Each service maintains its own subscription. Adding new consumers doesn’t affect existing ones.

Event Sourcing

Store events as the source of truth, not current state:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
# Instead of: UPDATE accounts SET balance = 150 WHERE id = 1

# Store events:
events = [
    {"type": "AccountOpened", "account_id": 1, "initial_balance": 100},
    {"type": "DepositMade", "account_id": 1, "amount": 75},
    {"type": "WithdrawalMade", "account_id": 1, "amount": 25},
]

# Derive current state by replaying events
def get_balance(account_id):
    balance = 0
    for event in get_events(account_id):
        if event["type"] == "AccountOpened":
            balance = event["initial_balance"]
        elif event["type"] == "DepositMade":
            balance += event["amount"]
        elif event["type"] == "WithdrawalMade":
            balance -= event["amount"]
    return balance

Benefits:

  • Complete audit trail
  • Can replay to any point in time
  • Debug by examining event history

Costs:

  • More complex queries
  • Storage grows forever
  • Eventual consistency challenges

CQRS (Command Query Responsibility Segregation)

Separate write models from read models:

CommandsEveWnrtitSetoMroedelEventsReadModelQueries
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# Write side: handles commands, emits events
def handle_place_order(command):
    # Validate
    if not inventory.has_stock(command.items):
        raise InsufficientStock()
    
    # Create event
    event = OrderCreated(
        order_id=generate_id(),
        items=command.items,
        customer_id=command.customer_id
    )
    
    # Store and publish
    event_store.append(event)
    publisher.publish(event)

# Read side: optimized for queries
def on_order_created(event):
    # Update denormalized read model
    db.execute("""
        INSERT INTO order_summaries (order_id, customer_name, total, status)
        SELECT %s, c.name, %s, 'pending'
        FROM customers c WHERE c.id = %s
    """, [event.order_id, event.total, event.customer_id])

Handling Failures

Idempotency

Events may be delivered more than once. Make handlers idempotent:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
def handle_order_created(event):
    # Check if already processed
    if db.exists("processed_events", event.id):
        return  # Skip duplicate
    
    # Process
    reserve_inventory(event.order_id, event.items)
    
    # Mark as processed
    db.insert("processed_events", {"event_id": event.id, "processed_at": now()})

Dead Letter Queues

Failed messages go somewhere for investigation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
def process_message(message):
    try:
        handle_event(message)
        channel.basic_ack(message.delivery_tag)
    except Exception as e:
        if message.redelivery_count > 3:
            # Send to dead letter queue
            channel.basic_publish(
                exchange='dead-letters',
                routing_key=message.routing_key,
                body=message.body
            )
            channel.basic_ack(message.delivery_tag)
        else:
            # Retry later
            channel.basic_nack(message.delivery_tag, requeue=True)

Saga Pattern

Coordinate multi-step processes with compensating actions:

O123r...deRCSCCrehcoosahmmSereppargdeegveunnaelss:PeaaIattnySeevmh::eeinnpRRttpeeoiflrnueygnaSdsuecPScFaIueayncsimvcsleeeunnsrttseoC!roynCtoinntuienue
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
class OrderSaga:
    def execute(self, order):
        try:
            # Step 1
            inventory_reservation = inventory.reserve(order.items)
            
            # Step 2
            payment = payments.charge(order.customer_id, order.total)
            
            # Step 3
            shipping.schedule(order)
            
        except ShippingError:
            # Compensate in reverse order
            payments.refund(payment.id)
            inventory.release(inventory_reservation.id)
            raise

Eventual Consistency

Event-driven systems are eventually consistent. Embrace it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Don't do this: expect immediate consistency
def place_order(order):
    publish(OrderCreated(order))
    # BAD: inventory might not be updated yet
    return get_inventory_status(order.items)

# Do this: design for eventual consistency
def place_order(order):
    publish(OrderCreated(order))
    return {"status": "processing", "order_id": order.id}
    # Client polls or subscribes for updates

Tell users “Your order is being processed” instead of showing stale data.

When Not to Use Events

Event-driven isn’t always better:

  • Simple CRUD apps: Adds unnecessary complexity
  • Strong consistency required: Bank transfers between accounts
  • Low latency critical: Real-time bidding, gaming
  • Small team: Operational overhead may not be worth it

Start synchronous. Add events where decoupling provides clear value.

Monitoring

Track these metrics:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
# Consumer lag: how far behind are we?
consumer_lag = latest_offset - consumer_offset

# Processing rate
events_processed_per_second = Counter('events_processed')

# Error rate
events_failed = Counter('events_failed', ['event_type', 'error'])

# End-to-end latency
event_latency = Histogram('event_latency_seconds')

Alert on:

  • Consumer lag growing
  • Dead letter queue filling up
  • Processing latency spikes

Event-driven architecture trades immediate consistency for loose coupling and resilience. Use it where those tradeoffs make sense.