Event-driven architecture (EDA) sounds enterprise-y. Kafka clusters. Schema registries. Teams of platform engineers. But the core concepts? They’re surprisingly accessible—and incredibly useful—even for small teams.

Why Events Matter (Even for Small Projects)

The alternative to events is tight coupling. Service A calls Service B directly. Service B calls Service C. Soon you have a distributed monolith where everything needs to know about everything else.

Events flip this model. Instead of “Service A tells Service B to do something,” it becomes “Service A announces what happened, and anyone who cares can respond.”

This shift enables:

  • Independent deployments — Services don’t need synchronized releases
  • Failure isolation — One service crashing doesn’t cascade
  • Flexible scaling — Heavy consumers can scale independently
  • Natural audit trails — Events are facts about what happened

Start with What You Have

You don’t need dedicated message infrastructure on day one.

Level 0: Database as Queue

Your database can be a perfectly fine event store:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
CREATE TABLE events (
  id SERIAL PRIMARY KEY,
  event_type VARCHAR(100) NOT NULL,
  payload JSONB NOT NULL,
  created_at TIMESTAMP DEFAULT NOW(),
  processed_at TIMESTAMP
);

CREATE INDEX idx_events_unprocessed 
  ON events (created_at) 
  WHERE processed_at IS NULL;

Publishers insert rows. Consumers poll and mark processed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
async def process_events():
    while True:
        events = await db.fetch("""
            SELECT * FROM events 
            WHERE processed_at IS NULL 
            ORDER BY created_at 
            LIMIT 100
            FOR UPDATE SKIP LOCKED
        """)
        
        for event in events:
            await handle_event(event)
            await db.execute(
                "UPDATE events SET processed_at = NOW() WHERE id = $1",
                event['id']
            )
        
        await asyncio.sleep(1)  # Poll interval

Pros: Zero new infrastructure. ACID guarantees. Works with your existing backup strategy.

Cons: Polling overhead. Single database as bottleneck. Not great for high throughput.

Level 1: Redis Streams

When you outgrow database polling, Redis Streams offer a nice middle ground:

 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
26
27
28
29
30
31
32
33
34
35
36
import redis.asyncio as redis

async def publish_event(event_type: str, payload: dict):
    r = redis.Redis()
    await r.xadd(
        f"events:{event_type}",
        {"payload": json.dumps(payload)}
    )

async def consume_events(event_type: str, consumer_group: str):
    r = redis.Redis()
    
    # Create consumer group if not exists
    try:
        await r.xgroup_create(
            f"events:{event_type}", 
            consumer_group, 
            id="0", 
            mkstream=True
        )
    except redis.ResponseError:
        pass  # Group exists
    
    while True:
        messages = await r.xreadgroup(
            consumer_group,
            "worker-1",
            {f"events:{event_type}": ">"},
            count=10,
            block=5000
        )
        
        for stream, events in messages:
            for event_id, data in events:
                await handle_event(json.loads(data[b"payload"]))
                await r.xack(stream, consumer_group, event_id)

Pros: Consumer groups for parallel processing. Automatic acknowledgment. Built-in backpressure.

Cons: Persistence requires configuration. No built-in dead letter queues.

Level 2: Managed Queues

When you need durability guarantees without operational overhead:

  • AWS SQS + SNS — Pub/sub with queue subscribers
  • Google Cloud Pub/Sub — Similar model
  • Azure Service Bus — Enterprise features

These are “boring” in the best way—reliable, scalable, managed.

Event Design That Doesn’t Suck

Events Are Facts, Not Commands

Bad event:

1
2
3
4
{
  "type": "SendWelcomeEmail",
  "user_id": "123"
}

Good event:

1
2
3
4
5
6
{
  "type": "UserRegistered",
  "user_id": "123",
  "email": "user@example.com",
  "registered_at": "2026-03-10T07:00:00Z"
}

The first is a command—it tells someone what to do. The second is a fact—it announces what happened. The difference matters because:

  1. Facts can have multiple consumers (email service, analytics, CRM sync)
  2. Facts are replayable (you can rebuild state from events)
  3. Facts don’t create temporal coupling (consumer doesn’t need to exist at publish time)

Include Enough Context

Events should be self-contained enough that consumers don’t need to call back to the publisher:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
{
  "type": "OrderPlaced",
  "order_id": "ord_789",
  "customer_id": "cust_123",
  "customer_email": "buyer@example.com",
  "items": [
    {"sku": "WIDGET-1", "quantity": 2, "price_cents": 1999}
  ],
  "total_cents": 3998,
  "placed_at": "2026-03-10T07:30:00Z"
}

Yes, this duplicates data. That’s okay. Network calls are expensive; storage is cheap.

Version From Day One

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
{
  "type": "UserRegistered",
  "version": "1.0",
  "data": {
    "user_id": "123",
    "email": "user@example.com"
  },
  "metadata": {
    "event_id": "evt_abc123",
    "timestamp": "2026-03-10T07:00:00Z",
    "source": "auth-service"
  }
}

When you need to add fields, bump the version. Consumers can handle multiple versions during migration.

Patterns That Pay Off

The Outbox Pattern

Problem: You update your database and publish an event, but what if the publish fails after the database commit?

Solution: Write the event to an “outbox” table in the same transaction, then publish from there:

 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
26
27
async def create_user(user_data: dict):
    async with db.transaction():
        user = await db.execute(
            "INSERT INTO users (...) VALUES (...) RETURNING *",
            user_data
        )
        
        await db.execute(
            "INSERT INTO outbox (event_type, payload) VALUES ($1, $2)",
            "UserRegistered",
            json.dumps(user)
        )
    
    # Separate process publishes from outbox

async def outbox_publisher():
    while True:
        events = await db.fetch(
            "SELECT * FROM outbox WHERE published_at IS NULL LIMIT 100"
        )
        for event in events:
            await publish_to_queue(event)
            await db.execute(
                "UPDATE outbox SET published_at = NOW() WHERE id = $1",
                event['id']
            )
        await asyncio.sleep(0.5)

Dead Letter Queues

Events that fail processing shouldn’t disappear:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
MAX_RETRIES = 3

async def process_with_dlq(event):
    try:
        await handle_event(event)
    except Exception as e:
        retries = event.get('_retries', 0)
        if retries < MAX_RETRIES:
            event['_retries'] = retries + 1
            await publish_to_retry_queue(event)
        else:
            await publish_to_dlq(event, error=str(e))
            alert_ops(f"Event failed permanently: {event['id']}")

Check your DLQ regularly. Those failures are bugs or edge cases waiting to be understood.

Idempotent Consumers

Events can be delivered more than once. Your consumers must handle this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
async def handle_user_registered(event):
    event_id = event['metadata']['event_id']
    
    # Check if already processed
    if await db.fetchval(
        "SELECT 1 FROM processed_events WHERE event_id = $1",
        event_id
    ):
        return  # Already handled
    
    # Do the work
    await send_welcome_email(event['data']['email'])
    
    # Mark as processed
    await db.execute(
        "INSERT INTO processed_events (event_id) VALUES ($1)",
        event_id
    )

When to Graduate to “Real” Infrastructure

Consider dedicated message brokers (Kafka, RabbitMQ, etc.) when:

  • Throughput exceeds thousands of events per second
  • Ordering guarantees matter across partitions
  • Replay capability is a core requirement
  • Multiple teams need independent consumption

Until then? Redis Streams or managed queues will take you surprisingly far.

The Mindset Shift

Event-driven architecture isn’t about the tools. It’s about asking different questions:

  • Instead of “who needs this data?” → “what happened that others might care about?”
  • Instead of “how do I update everything?” → “how do I announce this change?”
  • Instead of “what if that service is down?” → “how do I ensure this event persists?”

Start small. One event type. One consumer. Feel the decoupling. Then expand.

The best part? You can adopt this incrementally. Keep your existing REST APIs. Add events alongside them. Migrate piece by piece as the pattern proves its value.

That’s architecture that respects both your current constraints and your future ambitions.