DEV Community

Cover image for Event-driven architecture without the Kafka tax — lightweight patterns for small teams
Monalisa Das
Monalisa Das

Posted on Originally published at monalisadas-knowme.vercel.app

Event-driven architecture without the Kafka tax — lightweight patterns for small teams

We had eight engineers. Someone said we should 'add Kafka'. I asked why. The answer was: 'so services can talk to each other without coupling.' I said: 'that is a good goal. Kafka is one way to achieve it. It also needs a cluster, a schema registry, consumer group management, and someone who understands partition rebalancing at 2am.' We did not add Kafka.

What EDA actually is, stripped back

Event-driven architecture is a decoupling technique. Instead of service A calling service B directly, A emits an event — 'order created', 'payment failed', 'user signed up' — and B (or C, or D) reacts to it. The publisher does not know or care who is listening. That separation is the value. The message broker is just the delivery mechanism.

Kafka, EventBridge, and RabbitMQ are excellent delivery mechanisms. They are also significant operational commitments. For a team of four to twelve engineers, that commitment often costs more than the problem it solves.

Pattern 1: In-process typed event bus

If your services are modules in a monolith or a modular monorepo, you do not need a network hop to decouple them. An in-process event bus gives you publisher/subscriber separation with zero infrastructure.

// EventMap is the contract. Every event name and its payload shape, in one place.
// If you emit the wrong data, TypeScript tells you before it ships.
type EventMap = {
  'order.created':   { orderId: string; customerId: string; total: number }
  'order.cancelled': { orderId: string; reason: string }
  'payment.failed':  { orderId: string; errorCode: string }
  'payment.ok':      { orderId: string }
}

class EventBus {
  // Map of event name → Set of handlers.
  // Set (not Array) so the same handler can't register twice by accident.
  private handlers = new Map<string, Set<(payload: unknown) => void>>()

  on<K extends keyof EventMap>(event: K, handler: (payload: EventMap[K]) => void) {
    if (!this.handlers.has(event)) this.handlers.set(event, new Set())
    this.handlers.get(event)!.add(handler as (payload: unknown) => void)

    // Return an unsubscribe function — call it when the subscriber tears down.
    // No off() method to forget. No memory leaks.
    return () => this.handlers.get(event)?.delete(handler as (payload: unknown) => void)
  }

  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]) {
    // Every handler for this event fires. None know about each other.
    this.handlers.get(event)?.forEach(h => h(payload))
  }
}

// One bus, shared across the whole process via a single import.
export const bus = new EventBus()
export type { EventMap }
Enter fullscreen mode Exit fullscreen mode

A publisher looks like this — OrderService emitting without knowing who listens:

import { bus } from '../bus/EventBus'

export function placeOrder(customerId: string, total: number) {
  const orderId = `ORD-${String(orderSeq++).padStart(3, '0')}`

  // This one line triggers every subscribed service.
  // OrderService has no import of InventoryService, EmailService, or PaymentService.
  // It literally does not know they exist. That is decoupling.
  bus.emit('order.created', { orderId, customerId, total })
  return orderId
}
Enter fullscreen mode Exit fullscreen mode

Live sandbox: Run this in StackBlitz — five services, zero direct calls between them. Add a new bus.on() listener in any service file, save, and watch your handler fire without touching anything else. That is the point.

The EventMap type makes this refactor-safe — TypeScript will error if a publisher emits the wrong shape or a subscriber reads a field that does not exist. The return value of on is an unsubscribe function, so there are no memory leaks in short-lived contexts.

The limitation: events do not survive a process restart, and they do not cross service boundaries. That is fine for most intra-module communication. When you need durability or cross-service delivery, reach for the next pattern.

Pattern 2: Postgres as an event store (the outbox pattern)

You already have a database. Use it. An append-only events table is a durable event log that costs you nothing new to operate.

-- One migration. No new infrastructure.
CREATE TABLE events (
  id          BIGSERIAL PRIMARY KEY,
  type        TEXT        NOT NULL,
  payload     JSONB       NOT NULL,
  occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),
  processed   BOOLEAN     NOT NULL DEFAULT false
);
CREATE INDEX ON events (processed, occurred_at) WHERE NOT processed;
Enter fullscreen mode Exit fullscreen mode

The outbox pattern: write the event in the same database transaction as your domain change. This guarantees the event is emitted if and only if the domain change commits — no dual-write inconsistency.

// Both the domain change and the event write happen in one transaction.
// If the process crashes between them, both roll back. Nothing is lost.
// Nothing is partially applied.
async function createOrder(orderId: string, customerId: string, total: number) {
  await db.transaction(async (trx) => {
    await trx('orders').insert({ id: orderId, customer_id: customerId, total })
    await trx('events').insert({
      type: 'order.created',
      payload: { orderId, customerId, total },
    })
  })
}

// Background consumer — runs on a cron or setInterval.
// FOR UPDATE SKIP LOCKED means multiple consumers can run in parallel
// without stepping on each other. Locked rows are skipped, not blocked.
async function processEvents() {
  const events = await db.raw(`
    SELECT * FROM events
    WHERE processed = false
    ORDER BY occurred_at
    LIMIT 20
    FOR UPDATE SKIP LOCKED
  `)

  for (const ev of events.rows) {
    try {
      await handle(ev)
      await db('events').where({ id: ev.id }).update({ processed: true })
    } catch {
      // Do not mark processed — let the next poll retry it.
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

FOR UPDATE SKIP LOCKED is the key. It lets multiple consumer processes poll the same table without stepping on each other — locked rows are skipped rather than blocking. You get basic fan-out and parallelism with a single SQL clause.

Pattern 3: Combine them

In practice I use both. The in-process bus handles immediate, same-process reactions — send a notification, update an in-memory cache. The Postgres event table handles durability and cross-service work. The domain service writes to the DB events table; a separate consumer process polls it and emits on the in-process bus for local handlers to pick up.

The two patterns layer cleanly: persistence at the boundary, speed in the core. You add a message broker later if and when throughput demands it — and by then, your event contracts are already defined and tested.

When you actually need Kafka

Three signals: your event table is being polled so frequently that it is a meaningful fraction of your database load; you need guaranteed ordering across partitions that a single Postgres table cannot provide; or you have genuinely independent services deployed across teams who need a shared contract enforced by a schema registry. Those are real problems. They are also problems most teams do not have until well past the point where they can afford to solve them properly.

Start with the patterns that match your current scale. Migrate to a broker when the polling table actually hurts — not when someone reads a blog post about Kafka. You will know when. Your database will tell you.

Top comments (0)