Back to blog
architecturescalingeventsredisnode.jssaas

Event-Driven Architecture in Your SaaS: From Tight Coupling to Flexible Systems

By SaaS Masters21 maart 20267 min read

SaaS applications often grow organically: new features are added, integrations stacked, and before you know it you have a monolith where everything is intertwined. One change in your billing module breaks your notification system. Sound familiar?

Event-driven architecture (EDA) offers an elegant way out. Instead of direct couplings between components, they communicate through events — decoupled, scalable, and flexible. In this article, we'll dive deep into how to implement EDA in your SaaS, with concrete code examples and architecture patterns.

What is event-driven architecture?

EDA revolves around events: something that has happened. A user signed up, a payment was processed, a subscription was renewed. Instead of service A directly calling service B, A publishes an event. Any service that's interested listens in.

// Traditional (tight coupling)
async function createSubscription(userId, plan) {
  const subscription = await db.subscriptions.create({ userId, plan });
  await billingService.createInvoice(subscription);    // direct call
  await emailService.sendWelcome(userId);               // direct call
  await analyticsService.trackConversion(userId, plan); // direct call
  return subscription;
}

// Event-driven (loose coupling)
async function createSubscription(userId, plan) {
  const subscription = await db.subscriptions.create({ userId, plan });
  await eventBus.publish('subscription.created', {
    subscriptionId: subscription.id,
    userId,
    plan,
    createdAt: new Date().toISOString()
  });
  return subscription;
}

The difference is fundamental: the subscription service doesn't need to know who's listening. Billing, email, and analytics are independent consumers that each react at their own pace.

Core Concepts

Events, Commands, and Queries

Not everything is an event. It's important to distinguish:

  • Events: something that happened (past tense) — order.completed, user.registered
  • Commands: a request to do something — send.invoice, provision.workspace
  • Queries: a question for data — get.user.profile

Events are immutable facts. They've already happened and can't be "undone." This makes them perfect for audit trails and event sourcing.

Producers and Consumers

┌──────────────┐     ┌─────────────┐     ┌──────────────────┐
│   Producer   │────▶│  Event Bus  │────▶│   Consumer(s)    │
│ (publishes)  │     │  / Broker   │     │ (listens/reacts) │
└──────────────┘     └─────────────┘     └──────────────────┘

A producer publishes events without knowing who's listening. Consumers subscribe to specific event types. The event bus (or message broker) handles routing and delivery.

Implementation with Node.js and Redis

For many SaaS applications, a full message broker like Kafka is overkill. Redis Streams provide an excellent starting point: persistent, fast, and you're probably using Redis already.

Setting Up the Event Bus

// lib/event-bus.ts
import Redis from 'ioredis';

interface Event {
  type: string;
  data: Record<string, unknown>;
  metadata: {
    eventId: string;
    timestamp: string;
    source: string;
    correlationId?: string;
  };
}

class EventBus {
  private redis: Redis;
  private subscribers: Map<string, ((event: Event) => Promise<void>)[]>;

  constructor(redisUrl: string) {
    this.redis = new Redis(redisUrl);
    this.subscribers = new Map();
  }

  async publish(type: string, data: Record<string, unknown>, source: string): Promise<string> {
    const event: Event = {
      type,
      data,
      metadata: {
        eventId: crypto.randomUUID(),
        timestamp: new Date().toISOString(),
        source,
        correlationId: AsyncLocalStorage.getStore()?.correlationId,
      },
    };

    const messageId = await this.redis.xadd(
      `events:${type}`,
      '*',
      'payload', JSON.stringify(event)
    );

    const handlers = this.subscribers.get(type) || [];
    await Promise.allSettled(handlers.map(h => h(event)));

    return messageId;
  }

  subscribe(type: string, handler: (event: Event) => Promise<void>): void {
    const existing = this.subscribers.get(type) || [];
    this.subscribers.set(type, [...existing, handler]);
  }
}

export const eventBus = new EventBus(process.env.REDIS_URL!);

Consumer Groups for Scalable Processing

The real advantage of Redis Streams is consumer groups: multiple instances of the same service process events in parallel without duplicates.

// lib/event-consumer.ts
async function consumeEvents(
  streamKey: string,
  groupName: string,
  consumerName: string,
  handler: (event: Event) => Promise<void>
) {
  const redis = new Redis(process.env.REDIS_URL!);

  try {
    await redis.xgroup('CREATE', streamKey, groupName, '0', 'MKSTREAM');
  } catch (e) {
    // Group already exists
  }

  while (true) {
    const results = await redis.xreadgroup(
      'GROUP', groupName, consumerName,
      'COUNT', 10,
      'BLOCK', 5000,
      'STREAMS', streamKey, '>'
    );

    if (!results) continue;

    for (const [, messages] of results) {
      for (const [messageId, fields] of messages) {
        const event = JSON.parse(fields[1]) as Event;
        try {
          await handler(event);
          await redis.xack(streamKey, groupName, messageId);
        } catch (error) {
          console.error(`Event processing failed: ${messageId}`, error);
        }
      }
    }
  }
}

Practical Patterns for SaaS

1. Saga Pattern for Complex Workflows

Imagine: a customer upgrades their subscription. This requires multiple steps that must all succeed or be rolled back.

// sagas/subscription-upgrade.ts
class SubscriptionUpgradeSaga {
  private completedSteps: string[] = [];

  async execute(subscriptionId: string, newPlan: string) {
    try {
      await this.calculateProration(subscriptionId, newPlan);
      this.completedSteps.push('proration');

      await this.validatePayment(subscriptionId, newPlan);
      this.completedSteps.push('payment_validation');

      await this.upgradePlan(subscriptionId, newPlan);
      this.completedSteps.push('plan_upgrade');

      await this.activateFeatures(subscriptionId, newPlan);
      this.completedSteps.push('feature_activation');

      await eventBus.publish('subscription.upgraded', {
        subscriptionId, newPlan
      }, 'subscription-saga');

    } catch (error) {
      await this.rollback(subscriptionId);
      throw error;
    }
  }

  private async rollback(subscriptionId: string) {
    for (const step of this.completedSteps.reverse()) {
      await this.compensate(step, subscriptionId);
    }
  }
}

2. Event Sourcing for Audit Trails

For SaaS with compliance requirements (think financial data or healthcare), event sourcing is invaluable. Instead of storing only the current state, you store every event that led to that state.

// Traditional: you know WHAT the state is
{ status: 'active', plan: 'pro', seats: 10 }

// Event sourcing: you know HOW you got there
[
  { type: 'subscription.created', data: { plan: 'starter', seats: 1 }, at: '2026-01-15' },
  { type: 'subscription.upgraded', data: { plan: 'pro' }, at: '2026-02-01' },
  { type: 'seats.added', data: { count: 5 }, at: '2026-02-15' },
  { type: 'seats.added', data: { count: 4 }, at: '2026-03-01' },
]

3. CQRS: Separating Read and Write Paths

Command Query Responsibility Segregation (CQRS) combines perfectly with EDA. Write operations produce events; read operations work on optimized read models.

Write path:                         Read path:
┌─────────┐    ┌────────┐         ┌──────────────┐
│ Command  │───▶│ Events │────────▶│  Read Model  │
│ Handler  │    │ Store  │         │ (denormalized)│
└─────────┘    └────────┘         └──────┬───────┘
                                         │
                                    ┌────▼─────┐
                                    │  Query   │
                                    │  Handler │
                                    └──────────┘

This pattern is ideal for SaaS dashboards where you need complex aggregations without affecting your write performance.

Pitfalls and How to Avoid Them

1. Event Ordering

Events don't always arrive in the order they were published. Build your consumers to be idempotent and handle out-of-order events.

// Bad: depends on ordering
async function handleSeatChange(event) {
  await db.subscription.update({
    seats: event.data.newSeatCount
  });
}

// Good: idempotent with versioning
async function handleSeatChange(event) {
  await db.subscription.update({
    where: {
      id: event.data.subscriptionId,
      updatedAt: { lt: event.metadata.timestamp }
    },
    data: { seats: event.data.newSeatCount }
  });
}

2. Event Schema Evolution

Your events will change over time. Use versioning and be backwards-compatible:

// v1
{ type: 'user.registered', version: 1, data: { name: 'Jan' } }

// v2 — adds fields, removes nothing
{ type: 'user.registered', version: 2, data: { name: 'Jan', company: 'Acme' } }

3. Monitoring is Essential

With EDA, debugging becomes more complex. Invest in:

  • Correlation IDs: trace a request through all services
  • Dead letter queues: catch events that fail repeatedly
  • Event catalogs: document all event types and their schemas
  • Lag monitoring: how many unprocessed events are in the queue?

When (Not) to Go Event-Driven

Good fit:

  • Notifications and email triggers
  • Integrations with external systems
  • Analytics and reporting pipelines
  • Complex workflows spanning multiple services
  • Audit logging and compliance

Not a good fit:

  • Synchronous request/response flows (user waiting for result)
  • Simple CRUD operations
  • Teams smaller than 3-4 developers (overhead > benefit)

Conclusion

Event-driven architecture isn't a silver bullet, but for growing SaaS applications it's a powerful tool for managing complexity. Start small: identify one place where tight coupling is causing problems, and introduce events there. Build your event bus, add monitoring, and expand gradually.

The investment pays off in flexibility: new features become consumers you add without modifying existing code. That's the real power of EDA — your system becomes extensible by design.