Published on

Event-Driven Architecture: Rethinking How Systems Communicate

--
Authors
  • avatar
    Name
    Ergito Vilanculos

There's a fundamental shift happening in how we build systems. Instead of services constantly asking each other "hey, did anything happen?", they just announce "hey, this happened!" and whoever cares can react.

This is event-driven architecture, and it's a game changer.

Request-Response vs Events

Traditional architecture is like a phone call:

Service A: "Hey B, process this order"
Service B: "OK, processing... done"
Service A: "Thanks, now I can continue"

Event-driven is like a group chat:

Service A: "New order created!" (posts to channel)
Service B: (sees message) "I'll handle inventory"
Service C: (sees message) "I'll send confirmation email"
Service D: (sees message) "I'll update analytics"

In the first model, Service A has to wait for B. In the second, A just announces and moves on.

Why Go Event-Driven?

1. Loose Coupling

Services don't need to know about each other.

Request-Response:
OrderService must know about:
  - InventoryService
  - EmailService
  - AnalyticsService
  - PaymentService

Event-Driven:
OrderService just emits "OrderCreated"
Other services subscribe to events they care about

Adding a new service? Just subscribe to the events you need. No changes to existing services.

2. Scalability

Each consumer can scale independently.

High email volume? Scale email service.
High analytics load? Scale analytics service.
Order service doesn't care.

3. Resilience

If the email service is down, orders still process. Emails get sent when it comes back up.

Traditional:
OrderServiceEmailService (down)Order fails ❌

Event-Driven:
OrderServiceEvent Queue  (waits)EmailService (back up)Email sent ✓

4. Audit Trail

Events naturally create a history of everything that happened.

OrderCreated { orderId: 123, userId: 456, items: [...] }
PaymentProcessed { orderId: 123, amount: 99.99 }
InventoryReserved { orderId: 123, items: [...] }
OrderShipped { orderId: 123, trackingNumber: "..." }

Event Types

Domain Events

Something happened in your business domain.

// Examples
{
  type: "OrderCreated",
  data: { orderId: "123", customerId: "456", items: [...] }
}

{
  type: "PaymentFailed",
  data: { orderId: "123", reason: "insufficient_funds" }
}

{
  type: "UserRegistered",
  data: { userId: "789", email: "user@example.com" }
}

Integration Events

Events meant for external systems or bounded contexts.

// Published for other microservices
{
  type: "OrderReadyForShipment",
  data: { orderId: "123", warehouse: "NYC", priority: "high" }
}

System Events

Infrastructure-level events.

{
  type: "ServiceStarted",
  data: { service: "order-service", version: "1.2.3" }
}

{
  type: "DatabaseConnectionLost",
  data: { database: "orders-db", timestamp: "..." }
}

Event Anatomy

A well-structured event:

{
  // Metadata
  eventId: "uuid-here",           // Unique identifier
  eventType: "OrderCreated",      // What happened
  timestamp: "2025-01-22T10:30:00Z",
  version: "1.0",                 // Schema version
  source: "order-service",        // Who emitted it

  // Correlation
  correlationId: "request-123",   // Trace across services
  causationId: "event-456",       // What caused this event

  // Payload
  data: {
    orderId: "order-789",
    customerId: "cust-123",
    items: [...]
  }
}

Patterns

Event Notification

Just say something happened. Consumer fetches details if needed.

// Light event
{ type: "OrderCreated", data: { orderId: "123" } }

// Consumer calls back for details
GET /api/orders/123

Pros: Small events, always fresh data Cons: Extra API calls, coupling

Event-Carried State Transfer

Include all relevant data in the event.

// Fat event
{
  type: "OrderCreated",
  data: {
    orderId: "123",
    customer: { id: "456", name: "John", email: "..." },
    items: [...],
    totals: { subtotal: 99, tax: 8, total: 107 }
  }
}

Pros: Consumer has everything, no callbacks Cons: Bigger events, data might be stale

Event Sourcing

Store state as a sequence of events. More on this in a dedicated article!

// Instead of storing current state:
{ balance: 150 }

// Store events:
AccountCreated { amount: 100 }
MoneyDeposited { amount: 100 }
MoneyWithdrawn { amount: 50 }
// Current state = replay events = 150

Implementation Approaches

Simple: In-Memory Events

Good for monoliths or starting out.

class EventBus:
    def __init__(self):
        self.handlers = {}

    def subscribe(self, event_type, handler):
        if event_type not in self.handlers:
            self.handlers[event_type] = []
        self.handlers[event_type].append(handler)

    def publish(self, event):
        for handler in self.handlers.get(event['type'], []):
            handler(event)

# Usage
bus = EventBus()
bus.subscribe("OrderCreated", send_confirmation_email)
bus.subscribe("OrderCreated", update_inventory)
bus.publish({"type": "OrderCreated", "data": {...}})

Production: Message Broker

Use Kafka, RabbitMQ, or cloud services.

# Publishing
def create_order(order_data):
    order = save_to_database(order_data)

    event = {
        "type": "OrderCreated",
        "data": {"orderId": order.id, ...}
    }

    kafka.publish("orders", event)

    return order

# Consuming
@kafka.consumer("orders")
def handle_order_events(event):
    if event["type"] == "OrderCreated":
        send_confirmation_email(event["data"])

Challenges

Eventual Consistency

Data isn't immediately consistent across services.

User creates order → Order service saves it
Event published
Inventory service processes (later)

For a moment, inventory thinks item is available
when it's actually reserved.

Solutions:

  • Accept it (most cases it's fine)
  • Saga pattern for complex flows
  • UI shows "processing" state

Event Ordering

Events might arrive out of order.

OrderCreated (at 10:00)
OrderUpdated (at 10:01)
OrderCancelled (at 10:02)

What if OrderCancelled arrives before OrderCreated?

Solutions:

  • Use partitioning (same order → same partition)
  • Include sequence numbers
  • Idempotent handlers

Duplicate Events

Network issues can cause duplicates.

OrderCreatedprocessed
(network retry)
OrderCreated → processed again?

Solutions:

  • Idempotent handlers (same event = same result)
  • Track processed event IDs
def handle_order_created(event):
    event_id = event["eventId"]

    # Check if already processed
    if redis.sismember("processed_events", event_id):
        return  # Skip duplicate

    # Process
    create_inventory_reservation(event["data"])

    # Mark as processed
    redis.sadd("processed_events", event_id)

When NOT to Use Events

  • Simple CRUD apps: Overkill
  • Synchronous requirements: User needs immediate response
  • Strong consistency needs: Bank transfers between accounts
  • Small team/project: Added complexity might not be worth it

Event-driven architecture isn't about replacing all communication with events. It's about recognizing when async, decoupled communication makes more sense than synchronous calls.

Start with the parts of your system where services don't need immediate responses, and expand from there.

Next up, we'll dive into the tools that make this possible: Kafka, RabbitMQ, and more.

Questions? Drop them in the comments!

Until next time!