Best Practices for Domain Event Handling

Effective strategies for handling domain events in SaaS applications, focusing on design, publishing, consistency, and monitoring.

by Carl Poppa et al.
Best Practices for Domain Event Handling

Domain events are critical for managing changes in your system, especially in SaaS applications. Here’s what you need to know to handle them effectively:

  • What Are Domain Events?
    They capture significant state changes in your system, like UserRegistered or PaymentProcessed. These events are immutable and include key details such as ID, type, timestamp, and payload.

  • Why They Matter in SaaS?
    Domain events simplify scaling, improve system reliability, and allow real-time responsiveness. However, they come with challenges like event duplication, ordering issues, and schema evolution.

  • Key Practices for Success:

    1. Design Events Clearly: Use consistent naming, include necessary fields, and ensure immutability.
    2. Publish Reliably: Implement the Outbox Pattern to avoid data loss and ensure idempotency.
    3. Manage Consistency: Use patterns like Saga for multi-step transactions and CQRS for separating reads and writes.
    4. Monitor Everything: Set up centralized logging, track event chains, and visualize flows for better oversight.
    5. Handle Failures Gracefully: Use retries, Dead Letter Queues (DLQs), and compensating actions to recover from errors.

Example Workflow:
When a user cancels a subscription, an event like this might be published:

{
  "eventId": "evt_123456789",
  "eventType": "SubscriptionCanceled",
  "timestamp": "2025-03-31T14:30:00Z",
  "data": {
    "subscriptionId": "sub_abc123",
    "userId": "usr_xyz789",
    "cancellationReason": "upgrade",
    "effectiveDate": "2025-04-30T23:59:59Z"
  }
}

Domain vs. Integration events in DDD, why they matter, and how they differ

Event Design Standards

Designing domain events effectively requires using consistent structures and versioning to keep systems maintainable. Following established standards helps avoid issues like duplication, ordering mistakes, and schema conflicts. Below are clear rules and methods to structure and evolve events while tackling the challenges previously discussed.

Event Design Rules

To maintain clarity and system reliability, domain events should adhere to these principles:

RuleDescriptionExample
ImmutabilityEvents must not be altered after creationStore event data as read-only
Past Tense NamingUse names that describe completed actionsUserRegistered, PaymentProcessed
Required FieldsInclude key context informationEvent ID, timestamp, actor ID
Payload CompletenessProvide all necessary data for processingFull details of the state change
Schema DefinitionDefine event structure with strict typingUse JSON Schema or Protocol Buffers

When creating event payloads, only include data that’s absolutely necessary. For example, a SubscriptionCanceled event might look like this:

{
  "eventId": "evt_123456789",
  "eventType": "SubscriptionCanceled",
  "timestamp": "2025-03-31T14:30:00Z",
  "data": {
    "subscriptionId": "sub_abc123",
    "userId": "usr_xyz789",
    "cancellationReason": "upgrade",
    "effectiveDate": "2025-04-30T23:59:59Z"
  }
}

Event Versioning Methods

Versioning ensures that events remain compatible over time while allowing for updates to their structure. Here are three common approaches:

1. Schema Evolution

Update the schema gradually while maintaining compatibility:

// v1
{
  "eventType": "UserCreated",
  "userId": "123",
  "email": "user@example.com"
}

// v2
{
  "eventType": "UserCreated",
  "userId": "123",
  "email": "user@example.com",
  "phoneNumber": "optional_field"
}

2. Explicit Versioning

Clearly indicate the version in the event payload:

{
  "eventType": "UserCreated",
  "version": "2.0",
  "schemaUrl": "https://schema.example.com/events/user-created/v2",
  "payload": {
    // version-specific payload structure
  }
}

3. Event Upcasting

Convert older event versions into a newer format using upcasters:

class UserCreatedUpcaster {
  fromV1toV2(eventData) {
    return {
      ...eventData,
      phoneNumber: null,
      createdAt: new Date().toISOString(),
    };
  }
}

To manage older versions, enforce a strict deprecation policy while ensuring compatibility during the transition period.

Event Publishing Methods

Publishing domain events requires specific patterns to manage database transactions and ensure reliable message delivery. These methods help maintain system consistency and prevent data loss.

Outbox Pattern Implementation

The outbox pattern addresses the dual-write problem by storing events in the database alongside business data within a single transaction. Here’s how it works:

class OrderService {
  async createOrder(orderData) {
    // Start transaction
    const transaction = await db.beginTransaction();

    try {
      // Create order
      const order = await orderRepository.save(orderData);

      // Store event in outbox table
      await outboxRepository.save({
        aggregateId: order.id,
        eventType: "OrderCreated",
        payload: order,
        status: "PENDING",
        createdAt: new Date().toISOString(),
      });

      await transaction.commit();
    } catch (error) {
      await transaction.rollback();
      throw error;
    }
  }
}

A background relay regularly checks the outbox table and publishes any pending events:

class OutboxRelay {
  async processEvents() {
    const events = await outboxRepository.findPending();

    for (const event of events) {
      try {
        await messageBroker.publish(event);
        await outboxRepository.markAsPublished(event.id);
      } catch (error) {
        await outboxRepository.incrementRetries(event.id);
      }
    }
  }
}

Preventing Duplicate Events

To ensure each event is processed only once, use these strategies alongside the outbox pattern:

StrategyImplementationBenefits
Idempotency KeysAssign unique IDs to eventsEnsures events are handled only once
Deduplication StoreTrack processed event IDsAvoids duplicate processing
TTL-based CleanupRemove tracked IDs after a set periodKeeps the system efficient

Here’s an example of an idempotent event consumer:

class OrderEventConsumer {
  async handle(event) {
    const idempotencyKey = `${event.type}-${event.id}`;

    if (await deduplicationStore.exists(idempotencyKey)) {
      return; // Skip duplicate event
    }

    try {
      await processEvent(event);
      await deduplicationStore.store(idempotencyKey, {
        ttl: "24h",
      });
    } catch (error) {
      // Avoid marking as processed if an error occurs
      throw error;
    }
  }
}

Managing Failed Events

Handling failed events is critical for maintaining system stability. A Dead Letter Queue (DLQ) pattern can help:

class EventProcessor {
  async process(event) {
    try {
      await processEvent(event);
    } catch (error) {
      if (event.retryCount < 3) {
        await retryQueue.send(event);
      } else {
        await deadLetterQueue.send({
          originalEvent: event,
          error: error.message,
          failedAt: new Date().toISOString(),
          processingAttempts: event.processingAttempts,
        });

        await alerting.notify(
          `Event ${event.id} failed processing after 3 retries`
        );
      }
    }
  }
}

To monitor DLQ events effectively, use a dashboard that tracks:

  • Failed event counts and categories
  • Common error patterns
  • Retry history for each event
  • Resolution progress and actions taken

Transform your business idea into a revenue-generating SaaS in record time. Explore our curated collection of ready-to-launch boilerplates designed for developer-entrepreneurs who want to validate concepts, acquire customers, and start monetizing immediately.

Data Consistency Methods

Ensure consistent data in event-driven services by using reliable approaches.

Managing Eventual Consistency

Handle eventual consistency by using compensating transactions and tracking system states.

class PaymentProcessor {
  async processPayment(orderId: string) {
    const consistencyTracker = new ConsistencyTracker({
      operationId: orderId,
      maxRetries: 3,
      timeoutMs: 30000,
    });

    try {
      await consistencyTracker.startOperation();

      const payment = await paymentService.process(orderId);
      await orderService.updatePaymentStatus(orderId, payment.status);

      await consistencyTracker.completeOperation();
    } catch (error) {
      await paymentService.reversePayment(orderId);
      await orderService.revertPaymentStatus(orderId);

      await consistencyTracker.failOperation(error);
    }
  }
}

To monitor and address state inconsistencies, use a state tracking system:

interface ConsistencyState {
  aggregateId: string;
  currentState: string;
  expectedState: string;
  lastChecked: string;
  retryCount: number;
}

class ConsistencyStateManager {
  async checkState(state: ConsistencyState) {
    if (state.currentState !== state.expectedState) {
      if (state.retryCount < 3) {
        await this.scheduleRetry(state);
      } else {
        await this.notifyInconsistency(state);
      }
    }
  }
}

Saga Pattern for Events

When managing multi-step transactions, the saga pattern is a practical solution. It coordinates local transactions and handles failures with compensating actions:

class OrderSaga {
  private steps = [
    {
      execute: async (orderId) => await this.validateInventory(orderId),
      compensate: async (orderId) => await this.releaseInventory(orderId),
    },
    {
      execute: async (orderId) => await this.processPayment(orderId),
      compensate: async (orderId) => await this.refundPayment(orderId),
    },
    {
      execute: async (orderId) => await this.createShipment(orderId),
      compensate: async (orderId) => await this.cancelShipment(orderId),
    },
  ];

  async execute(orderId: string) {
    const sagaLog = new SagaLog(orderId);

    for (let i = 0; i < this.steps.length; i++) {
      try {
        await this.steps[i].execute(orderId);
        await sagaLog.recordSuccess(i);
      } catch (error) {
        await this.compensate(orderId, i);
        throw error;
      }
    }
  }

  private async compensate(orderId: string, failedStepIndex: number) {
    for (let i = failedStepIndex; i >= 0; i--) {
      await this.steps[i].compensate(orderId);
    }
  }
}

CQRS Implementation

To improve system efficiency, separate read and write operations with a CQRS (Command Query Responsibility Segregation) approach. Here’s how it works:

// Command side
class OrderCommandHandler {
  async handle(command: CreateOrderCommand) {
    const order = await this.orderRepository.create(command);

    await this.eventBus.publish({
      type: "OrderCreated",
      payload: order,
      timestamp: new Date().toISOString(),
    });
  }
}

// Query side
class OrderQueryHandler {
  async getOrder(orderId: string) {
    return await this.orderReadModel.findById(orderId);
  }
}

// Event handler to update read model
class OrderEventHandler {
  async handle(event: OrderCreatedEvent) {
    await this.orderReadModel.create({
      id: event.payload.id,
      status: event.payload.status,
      items: event.payload.items,
      lastUpdated: event.timestamp,
    });
  }
}
Operation TypeStorage SolutionBenefits
CommandsEvent StoreKeeps a complete audit trail
QueriesRedis CacheEnables fast read operations
Read ModelsMongoDBOffers flexible document structures

This separation ensures that read and write operations can scale independently, while event-driven updates maintain consistency across the system.

Event System Monitoring

Event Log Management

Keep all your event logs in one place to easily track distributed events:

class EventLogger {
  async logEvent(event: DomainEvent) {
    await this.elasticClient.index({
      index: "domain-events",
      body: {
        eventId: event.id,
        eventType: event.type,
        aggregateId: event.aggregateId,
        timestamp: new Date().toISOString(),
        payload: event.payload,
        metadata: {
          service: event.source,
          environment: process.env.NODE_ENV,
          version: event.version,
        },
      },
    });
  }

  async queryEvents(criteria: EventSearchCriteria) {
    return await this.elasticClient.search({
      index: "domain-events",
      body: {
        query: {
          bool: {
            must: [
              { match: { eventType: criteria.type } },
              {
                range: {
                  timestamp: {
                    gte: criteria.startDate,
                    lte: criteria.endDate,
                  },
                },
              },
            ],
          },
        },
      },
    });
  }
}

For deeper insights, consider tracing event chains to pinpoint system bottlenecks.

Event Chain Tracking

Track event chains to identify where delays or issues occur:

class EventTracer {
  private tracer: Tracer;

  async traceEventChain(event: DomainEvent) {
    const span = this.tracer.startSpan(`process.${event.type}`);

    try {
      span.setAttribute("event.id", event.id);
      span.setAttribute("aggregate.id", event.aggregateId);

      await this.processEvent(event);

      span.setStatus({ code: SpanStatusCode.OK });
    } catch (error) {
      span.setStatus({
        code: SpanStatusCode.ERROR,
        message: error.message,
      });
      throw error;
    } finally {
      span.end();
    }
  }
}
Tracing AspectToolPurpose
Event FlowOpenTelemetryTrack events end-to-end
MetricsPrometheusMonitor system performance
AlertsGrafanaGet real-time notifications

Visualizing these traces can make event flows easier to understand.

Event Flow Diagrams

Use tools like Mermaid.js to create real-time diagrams of your event flows:

class EventFlowVisualizer {
  generateFlowDiagram(events: DomainEvent[]) {
    return `
    graph TD
      ${events
        .map(
          (event) => `
        ${event.id}[${event.type}]
        ${event.causationId && `${event.causationId} --> ${event.id}`}
      `
        )
        .join("\n")}
    `;
  }
}

Stay on top of your system’s performance by tracking key metrics:

interface EventMetrics {
  processingTime: number;
  retryCount: number;
  failureRate: number;
  throughput: number;
}

class EventMonitor {
  async trackMetrics(eventType: string): Promise<EventMetrics> {
    const metrics = await this.metricsStore.query({
      eventType,
      timeRange: "24h",
    });

    return {
      processingTime: metrics.averageProcessingTime,
      retryCount: metrics.totalRetries,
      failureRate: metrics.failurePercentage,
      throughput: metrics.eventsPerSecond,
    };
  }
}

Focus on these critical areas:

  • Event processing times
  • Frequency of failed events
  • Completion times for event chains
  • Overall system throughput
  • Service health and reliability

Ready-Made SaaS Tools

After covering detailed event handling methods, ready-made SaaS tools provide a faster way to get started. Building domain event handling from scratch can be a challenge. SaaS boilerplates come pre-equipped with event handling features and core functionalities, allowing you to focus on your business logic while ensuring consistency and reliability in event management.

Best SaaS Boilerplates

Best SaaS Boilerplates

Best SaaS Boilerplates is a curated directory of starter kits that combine essential event handling tools with core SaaS functionalities.

Feature CategoryIncluded Capabilities
Event HandlingBackground job processing, Message queue integration
Core InfrastructureAuthentication, Multi-tenancy, Database integration
Deployment ToolsOne-click deployment, Pre-built themes

Here are some key benefits of these boilerplates:

  • Background Processing
    Many boilerplates come with built-in background job processing, ensuring reliable event publishing.
  • Simplified Infrastructure
    Pre-configured database integrations make it easier to implement patterns like the Outbox Pattern. Additionally, message brokers are set up in advance, saving you time and effort.
  • Framework Support
    Compatible with popular frameworks like Next.js, Django, and Laravel, these boilerplates are optimized for efficient event processing.

”When it came time to start building my new SaaS, a friend pointed me to Best SaaS Boilerplates. It had absolutely everything I needed, and saved me hours hunting down boilerplates on Twitter, HackerNews and ProductHunt.” - Lesley Sim, Founder, Newsletter Glue

The platform currently features 119 boilerplates, offering options from free to premium solutions priced at an average of $200.

Select a boilerplate that aligns with your tech stack and ensures strong event handling capabilities. Incorporate these tools to speed up your event-driven architecture, and stay tuned for more optimization strategies in the next section.

Summary

Key Points Review

Handling domain events effectively involves several areas of focus to ensure your system remains reliable. Here’s a quick breakdown of important practices:

AreaBest Practices
Event Design- Use clear naming conventions
- Make events immutable
- Manage versioning
Publishing- Apply the Outbox pattern
- Perform idempotency checks
- Use retry mechanisms
Data Consistency- Embrace eventual consistency
- Utilize the Saga pattern
- Implement CQRS
Monitoring- Set up centralized logging
- Track event chains
- Visualize event flows

Implementation Guide

To put these practices into action, follow these steps to strengthen your event handling approach:

  1. Set Up Event Infrastructure
    Incorporate the Outbox pattern alongside a message broker and database integration.
  2. Define Event Design Standards
    Create strict schemas for events and enforce version control to maintain consistency.
  3. Deploy Monitoring Tools
    Use centralized logging, set up performance metrics, establish alert thresholds, and enable flow visualization for better oversight.
  4. Establish Recovery Mechanisms
    Automate recovery processes with retry strategies, such as exponential backoff, to handle common failures.

Effective event handling ensures both data consistency and system reliability. These strategies can be seamlessly integrated with tools like Best SaaS Boilerplates to streamline your SaaS development process.

Below you’ll find three highly recommended SaaS boilerplates voted by the community this month.