Terug naar blog
architectuurscalingeventsredisnode.jssaas

Event-Driven Architecture in je SaaS: van tight coupling naar flexibele systemen

Door SaaS Masters21 maart 20267 min leestijd

SaaS-applicaties groeien vaak organisch: nieuwe features worden toegevoegd, integraties gestapeld, en voor je het weet heb je een monoliet waarin alles met alles verweven is. Eén wijziging in je facturatiemodule breekt je notificatiesysteem. Herkenbaar?

Event-driven architecture (EDA) biedt een elegante uitweg. In plaats van directe koppelingen tussen componenten, communiceren ze via events — ontkoppeld, schaalbaar en flexibel. In dit artikel duiken we diep in hoe je EDA implementeert in je SaaS, met concrete codevoorbeelden en architectuurpatronen.

Wat is event-driven architecture?

Bij EDA draait alles om events: iets dat is gebeurd. Een gebruiker heeft zich aangemeld, een betaling is verwerkt, een abonnement is verlengd. In plaats van dat service A direct service B aanroept, publiceert A een event. Elke service die geïnteresseerd is, luistert mee.

// Traditioneel (tight coupling)
async function createSubscription(userId, plan) {
  const subscription = await db.subscriptions.create({ userId, plan });
  await billingService.createInvoice(subscription);    // directe aanroep
  await emailService.sendWelcome(userId);               // directe aanroep
  await analyticsService.trackConversion(userId, plan); // directe aanroep
  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;
}

Het verschil is fundamenteel: de subscription-service hoeft niet te weten wie er naar luistert. Billing, email en analytics zijn onafhankelijke consumers die elk op hun eigen tempo reageren.

Kernconcepten

Events, Commands en Queries

Niet alles is een event. Het is belangrijk om onderscheid te maken:

  • Events: iets dat is gebeurd (past tense) — order.completed, user.registered
  • Commands: een verzoek om iets te doen — send.invoice, provision.workspace
  • Queries: een vraag om data — get.user.profile

Events zijn immutable feiten. Ze zijn al gebeurd en kunnen niet worden "teruggedraaid". Dit maakt ze perfect voor audit trails en event sourcing.

Producers en Consumers

┌──────────────┐     ┌─────────────┐     ┌──────────────────┐
│   Producer    │────▶│  Event Bus  │────▶│   Consumer(s)    │
│ (publiceert)  │     │  / Broker   │     │ (luistert/reageert)│
└──────────────┘     └─────────────┘     └──────────────────┘

Een producer publiceert events zonder te weten wie er luistert. Consumers abonneren zich op specifieke event-types. De event bus (of message broker) zorgt voor routing en delivery.

Implementatie met Node.js en Redis

Voor veel SaaS-applicaties is een volledige message broker als Kafka overkill. Redis Streams bieden een uitstekend startpunt: persistent, snel, en je gebruikt Redis waarschijnlijk al.

Event Bus opzetten

// 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 voor schaalbare verwerking

Het echte voordeel van Redis Streams is consumer groups: meerdere instances van dezelfde service verwerken events parallel, zonder duplicaten.

// 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 bestaat al
  }

  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);
        }
      }
    }
  }
}

Praktijkpatronen voor SaaS

1. Saga Pattern voor complexe workflows

Stel je voor: een klant upgradet zijn abonnement. Dit vereist meerdere stappen die allemaal moeten slagen of worden teruggedraaid.

// 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 voor audit trails

Voor SaaS met compliance-eisen (denk aan financiële data of healthcare) is event sourcing goud waard. In plaats van alleen de huidige state op te slaan, bewaar je elk event dat tot die state heeft geleid.

// Traditioneel: je weet WAT de state is
{ status: 'active', plan: 'pro', seats: 10 }

// Event sourcing: je weet HOE je daar gekomen bent
[
  { 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: lees- en schrijfpaden scheiden

Command Query Responsibility Segregation (CQRS) combineert perfect met EDA. Schrijfoperaties produceren events; leesoperaties werken op geoptimaliseerde read models.

Schrijfpad:                         Leespad:
┌─────────┐    ┌────────┐         ┌──────────────┐
│ Command  │───▶│ Events │────────▶│  Read Model  │
│ Handler  │    │ Store  │         │ (gedenorm.)  │
└─────────┘    └────────┘         └──────┬───────┘
                                         │
                                    ┌────▼─────┐
                                    │  Query   │
                                    │  Handler │
                                    └──────────┘

Dit patroon is ideaal voor SaaS-dashboards waar je complexe aggregaties nodig hebt zonder je schrijfperformance te beïnvloeden.

Valkuilen en hoe je ze vermijdt

1. Event ordering

Events komen niet altijd aan in de volgorde waarin ze zijn gepubliceerd. Bouw je consumers zo dat ze idempotent zijn en met out-of-order events om kunnen gaan.

// Slecht: afhankelijk van volgorde
async function handleSeatChange(event) {
  await db.subscription.update({
    seats: event.data.newSeatCount
  });
}

// Goed: idempotent met 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 evolutie

Je events gaan veranderen over tijd. Gebruik versioning en wees backwards-compatible:

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

// v2 — voegt velden toe, verwijdert niks
{ type: 'user.registered', version: 2, data: { name: 'Jan', company: 'Acme' } }

3. Monitoring is essentieel

Met EDA wordt debugging complexer. Investeer in:

  • Correlation IDs: volg een request door alle services
  • Dead letter queues: vang events op die herhaaldelijk falen
  • Event catalogs: documenteer alle event-types en hun schema's
  • Lag monitoring: hoeveel onverwerkte events staan er in de queue?

Wanneer (niet) event-driven?

Wel geschikt:

  • Notificaties en email triggers
  • Integraties met externe systemen
  • Analytics en reporting pipelines
  • Complexe workflows met meerdere services
  • Audit logging en compliance

Niet geschikt:

  • Synchrone request/response flows (gebruiker wacht op resultaat)
  • Simpele CRUD-operaties
  • Teams kleiner dan 3-4 developers (overhead > voordeel)

Conclusie

Event-driven architecture is geen silver bullet, maar voor groeiende SaaS-applicaties is het een krachtig middel om complexiteit te beheersen. Begin klein: identificeer één plek waar tight coupling problemen veroorzaakt, en introduceer daar events. Bouw je event bus, voeg monitoring toe, en breid geleidelijk uit.

De investering betaalt zich terug in flexibiliteit: nieuwe features worden consumers die je toevoegt zonder bestaande code te wijzigen. Dat is de echte kracht van EDA — je systeem wordt extensible by design.