Best Practices for Domain Event Handling
Effective strategies for handling domain events in SaaS applications, focusing on design, publishing, consistency, and monitoring.

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, likeUserRegistered
orPaymentProcessed
. 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:
- Design Events Clearly: Use consistent naming, include necessary fields, and ensure immutability.
- Publish Reliably: Implement the Outbox Pattern to avoid data loss and ensure idempotency.
- Manage Consistency: Use patterns like Saga for multi-step transactions and CQRS for separating reads and writes.
- Monitor Everything: Set up centralized logging, track event chains, and visualize flows for better oversight.
- 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:
Rule | Description | Example |
---|---|---|
Immutability | Events must not be altered after creation | Store event data as read-only |
Past Tense Naming | Use names that describe completed actions | UserRegistered , PaymentProcessed |
Required Fields | Include key context information | Event ID, timestamp, actor ID |
Payload Completeness | Provide all necessary data for processing | Full details of the state change |
Schema Definition | Define event structure with strict typing | Use 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:
Strategy | Implementation | Benefits |
---|---|---|
Idempotency Keys | Assign unique IDs to events | Ensures events are handled only once |
Deduplication Store | Track processed event IDs | Avoids duplicate processing |
TTL-based Cleanup | Remove tracked IDs after a set period | Keeps 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
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 Type | Storage Solution | Benefits |
---|---|---|
Commands | Event Store | Keeps a complete audit trail |
Queries | Redis Cache | Enables fast read operations |
Read Models | MongoDB | Offers 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 Aspect | Tool | Purpose |
---|---|---|
Event Flow | OpenTelemetry | Track events end-to-end |
Metrics | Prometheus | Monitor system performance |
Alerts | Grafana | Get 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 is a curated directory of starter kits that combine essential event handling tools with core SaaS functionalities.
Feature Category | Included Capabilities |
---|---|
Event Handling | Background job processing, Message queue integration |
Core Infrastructure | Authentication, Multi-tenancy, Database integration |
Deployment Tools | One-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:
Area | Best 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:
- Set Up Event Infrastructure
Incorporate the Outbox pattern alongside a message broker and database integration. - Define Event Design Standards
Create strict schemas for events and enforce version control to maintain consistency. - Deploy Monitoring Tools
Use centralized logging, set up performance metrics, establish alert thresholds, and enable flow visualization for better oversight. - 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.
Recommended SaaS Boilerplates
Below you’ll find three highly recommended SaaS boilerplates voted by the community this month.