saas-architecture event sourcingcqrs patternevent-driven architecture

Event Sourcing & CQRS: Complete Implementation Guide 2024

Master event sourcing and CQRS pattern implementation with practical examples, architectural insights, and real-world PropTech applications. Start building today.

📖 15 min read 📅 June 5, 2026 ✍ By PropTechUSA AI
15m
Read Time
2.9k
Words
17
Sections

Modern software architecture demands systems that can scale, audit, and evolve without compromising data integrity. Traditional CRUD operations often fall short when building complex business applications that require complete audit trails, temporal queries, and high-performance read operations. This is where event sourcing combined with the CQRS pattern transforms how we architect resilient, scalable systems.

At PropTechUSA.ai, we've implemented event-driven architecture across [property](/offer-check) management platforms handling millions of transactions, tenant interactions, and regulatory compliance events. The combination of event sourcing and CQRS has proven invaluable for maintaining data consistency while enabling lightning-fast queries and comprehensive audit capabilities.

Understanding Event Sourcing and CQRS Fundamentals

Event sourcing represents a paradigm shift from storing current state to persisting the sequence of events that led to that state. Instead of updating records in place, every state change becomes an immutable event stored in an append-only log.

Core Event Sourcing Principles

Event sourcing treats events as the source of truth. Each business operation generates one or more domain events that capture what happened, when it occurred, and the relevant context. The current state of any entity can be reconstructed by replaying these events from the beginning.

Consider a property lease management system. Instead of updating a lease record directly, every action becomes an event:

typescript
interface PropertyLeaseEvent {

eventId: string;

aggregateId: string;

eventType: string;

timestamp: Date;

version: number;

data: unknown;

}

class LeaseCreatedEvent implements PropertyLeaseEvent {

constructor(

public eventId: string,

public aggregateId: string,

public timestamp: Date,

public version: number,

public data: {

tenantId: string;

propertyId: string;

startDate: Date;

endDate: Date;

monthlyRent: number;

}

) {

this.eventType = 'LeaseCreated';

}

}

CQRS Pattern Integration

Command Query Responsibility Segregation (CQRS) separates read and write operations into distinct models. Commands modify state and generate events, while queries read from optimized projections. This separation enables independent scaling and optimization of read and write operations.

The write side focuses on business logic validation and event generation, while the read side maintains denormalized views optimized for specific query patterns. This architectural separation proves particularly powerful in PropTech applications where complex reporting requirements coexist with high-frequency transactional operations.

Event-Driven Architecture Benefits

Event-driven architecture provides natural decoupling between system components. Services communicate through events rather than direct [API](/workers) calls, enabling loose coupling and independent deployment. When a lease payment is processed, multiple bounded contexts can react independently: accounting updates financial records, notifications send confirmation emails, and analytics systems update dashboards.

This approach significantly improves system resilience. If the notification service is temporarily unavailable, payment processing continues uninterrupted, and notifications are processed once the service recovers.

Implementing the Command Side Architecture

The command side of a CQRS system focuses on business logic execution and event generation. It consists of command handlers, domain aggregates, and event stores that work together to ensure business rules are enforced while maintaining a complete audit trail.

Command Handlers and Domain Logic

Command handlers receive business commands, load the relevant aggregate from the event store, execute business logic, and persist resulting events. Here's a practical implementation for property management:

typescript
class ProcessLeasePaymentHandler {

constructor(

private eventStore: EventStore,

private eventBus: EventBus

) {}

async handle(command: ProcessLeasePaymentCommand): Promise<void> {

// Load aggregate from event store

const lease = await this.loadLeaseAggregate(command.leaseId);

// Execute business logic

const events = lease.processPayment(

command.amount,

command.paymentDate,

command.paymentMethod

);

// Persist events

await this.eventStore.saveEvents(

command.leaseId,

events,

lease.version

);

// Publish events for projections and other bounded contexts

await this.eventBus.publishAll(events);

}

private async loadLeaseAggregate(leaseId: string): Promise<LeaseAggregate> {

const events = await this.eventStore.getEvents(leaseId);

return LeaseAggregate.fromHistory(events);

}

}

Aggregate Design and Event Generation

Domain aggregates encapsulate business rules and generate events based on business operations. They maintain internal state by applying events and ensure invariants are preserved:

typescript
class LeaseAggregate {

private constructor(

public readonly id: string,

public version: number,

private state: LeaseState

) {}

static fromHistory(events: PropertyLeaseEvent[]): LeaseAggregate {

const aggregate = new LeaseAggregate(events[0].aggregateId, 0, new LeaseState());

events.forEach(event => aggregate.apply(event));

return aggregate;

}

processPayment(

amount: number,

paymentDate: Date,

paymentMethod: string

): PropertyLeaseEvent[] {

// Business rule validation

if (amount <= 0) {

throw new Error('Payment amount must be positive');

}

if (this.state.status !== 'Active') {

throw new Error('Cannot process payment for inactive lease');

}

// Generate appropriate events

const events: PropertyLeaseEvent[] = [];

events.push(new PaymentReceivedEvent(

generateEventId(),

this.id,

new Date(),

this.version + 1,

{ amount, paymentDate, paymentMethod }

));

// Check if payment completes the monthly obligation

if (this.wouldCompleteMonthlyPayment(amount)) {

events.push(new MonthlyPaymentCompletedEvent(

generateEventId(),

this.id,

new Date(),

this.version + 2,

{ month: paymentDate.getMonth(), year: paymentDate.getFullYear() }

));

}

return events;

}

private apply(event: PropertyLeaseEvent): void {

this.version = event.version;

switch (event.eventType) {

case 'LeaseCreated':

this.state.applyLeaseCreated(event.data);

break;

case 'PaymentReceived':

this.state.applyPaymentReceived(event.data);

break;

// Handle other event types

}

}

}

Event Store Implementation

The event store provides append-only persistence for events with optimistic concurrency control. Here's a simplified implementation using a relational database:

typescript
class PostgreSQLEventStore implements EventStore {

constructor(private db: DatabaseConnection) {}

async saveEvents(

aggregateId: string,

events: PropertyLeaseEvent[],

expectedVersion: number

): Promise<void> {

const transaction = await this.db.beginTransaction();

try {

// Optimistic concurrency check

const currentVersion = await this.getCurrentVersion(aggregateId, transaction);

if (currentVersion !== expectedVersion) {

throw new ConcurrencyError(

Expected version ${expectedVersion}, but current version is ${currentVersion}

);

}

// Insert events

for (const event of events) {

await transaction.query(

INSERT INTO events (event_id, aggregate_id, event_type, version, timestamp, data)

VALUES ($1, $2, $3, $4, $5, $6),

[

event.eventId,

event.aggregateId,

event.eventType,

event.version,

event.timestamp,

JSON.stringify(event.data)

]

);

}

await transaction.commit();

} catch (error) {

await transaction.rollback();

throw error;

}

}

async getEvents(aggregateId: string): Promise<PropertyLeaseEvent[]> {

const result = await this.db.query(

'SELECT * FROM events WHERE aggregate_id = $1 ORDER BY version',

[aggregateId]

);

return result.rows.map(row => this.deserializeEvent(row));

}

}

Building Optimized Query Projections

The query side of CQRS focuses on building denormalized read models optimized for specific query patterns. Projections subscribe to events and maintain these specialized views.

Projection Event Handlers

Projection handlers listen to events and update read models accordingly. Each projection serves specific query requirements:

typescript
class TenantDashboardProjection {

constructor(

private readModelStore: ReadModelStore,

private eventBus: EventBus

) {

this.subscribeToEvents();

}

private subscribeToEvents(): void {

this.eventBus.subscribe('LeaseCreated', this.handleLeaseCreated.bind(this));

this.eventBus.subscribe('PaymentReceived', this.handlePaymentReceived.bind(this));

this.eventBus.subscribe('MaintenanceRequestCreated', this.handleMaintenanceRequest.bind(this));

}

private async handleLeaseCreated(event: LeaseCreatedEvent): Promise<void> {

const tenantDashboard = await this.readModelStore.getTenantDashboard(event.data.tenantId) ||

new TenantDashboardReadModel(event.data.tenantId);

tenantDashboard.addLease({

leaseId: event.aggregateId,

propertyAddress: await this.getPropertyAddress(event.data.propertyId),

monthlyRent: event.data.monthlyRent,

startDate: event.data.startDate,

endDate: event.data.endDate

});

await this.readModelStore.saveTenantDashboard(tenantDashboard);

}

private async handlePaymentReceived(event: PaymentReceivedEvent): Promise<void> {

const tenantId = await this.getTenantIdFromLease(event.aggregateId);

const [dashboard](/dashboards) = await this.readModelStore.getTenantDashboard(tenantId);

if (dashboard) {

dashboard.recordPayment({

leaseId: event.aggregateId,

amount: event.data.amount,

date: event.data.paymentDate,

method: event.data.paymentMethod

});

await this.readModelStore.saveTenantDashboard(dashboard);

}

}

}

Read Model Optimization Strategies

Read models should be designed for specific query patterns. In PropTech applications, different stakeholders require different views of the same data:

typescript
// Optimized for tenant mobile app

interface TenantDashboardReadModel {

tenantId: string;

activeLeases: {

propertyAddress: string;

monthlyRent: number;

nextPaymentDue: Date;

outstandingBalance: number;

}[];

recentPayments: PaymentSummary[];

pendingMaintenanceRequests: MaintenanceRequestSummary[];

importantNotices: Notice[];

}

// Optimized for property manager analytics

interface PropertyManagerAnalyticsModel {

managerId: string;

portfolioSummary: {

totalProperties: number;

occupancyRate: number;

monthlyRevenue: number;

maintenanceRequestsOpen: number;

};

revenueByProperty: PropertyRevenue[];

tenantSatisfactionMetrics: SatisfactionMetric[];

upcomingLeaseExpirations: LeaseExpiration[];

}

Query Service Implementation

Query services provide clean APIs for accessing read models:

typescript
class PropertyQueryService {

constructor(private readModelStore: ReadModelStore) {}

async getTenantDashboard(tenantId: string): Promise<TenantDashboardReadModel | null> {

return this.readModelStore.getTenantDashboard(tenantId);

}

async getPropertyManagerAnalytics(

managerId: string,

dateRange: DateRange

): Promise<PropertyManagerAnalyticsModel> {

return this.readModelStore.getPropertyManagerAnalytics(managerId, dateRange);

}

async searchProperties(criteria: PropertySearchCriteria): Promise<PropertySearchResult[]> {

// Leverage optimized search indexes

return this.readModelStore.searchProperties(criteria);

}

}

💡
Pro TipDesign read models based on actual UI requirements rather than trying to create generic models. Each screen or report should have its own optimized projection.

Best Practices and Production Considerations

Implementing event sourcing and CQRS in production requires careful attention to operational concerns, performance optimization, and system reliability.

Event Schema Evolution

Events are immutable, but business requirements evolve. Plan for schema changes from the beginning:

typescript
interface EventMetadata {

schemaVersion: string;

eventType: string;

correlationId?: string;

causationId?: string;

}

class EventUpgrader {

private upgraders = new Map<string, (event: any) => any>();

registerUpgrader(fromVersion: string, toVersion: string, upgrader: (event: any) => any): void {

this.upgraders.set(${fromVersion}->${toVersion}, upgrader);

}

upgradeEvent(event: any): any {

const currentVersion = event.metadata.schemaVersion;

const targetVersion = this.getLatestVersion(event.eventType);

if (currentVersion === targetVersion) {

return event;

}

// Apply version upgrades sequentially

let upgradedEvent = event;

let version = currentVersion;

while (version !== targetVersion) {

const nextVersion = this.getNextVersion(version);

const upgrader = this.upgraders.get(${version}->${nextVersion});

if (!upgrader) {

throw new Error(No upgrader found for ${version} -> ${nextVersion});

}

upgradedEvent = upgrader(upgradedEvent);

version = nextVersion;

}

return upgradedEvent;

}

}

Snapshot Strategies

For aggregates with long event histories, snapshots improve performance by providing a cached state at a specific point in time:

typescript
class SnapshotStore {

async getSnapshot(aggregateId: string): Promise<AggregateSnapshot | null> {

// Retrieve latest snapshot from cache or database

return this.store.getSnapshot(aggregateId);

}

async saveSnapshot(aggregateId: string, snapshot: AggregateSnapshot): Promise<void> {

await this.store.saveSnapshot(aggregateId, snapshot);

}

}

class OptimizedEventStore extends PostgreSQLEventStore {

constructor(

db: DatabaseConnection,

private snapshotStore: SnapshotStore,

private snapshotFrequency = 100

) {

super(db);

}

async getEvents(aggregateId: string): Promise<PropertyLeaseEvent[]> {

const snapshot = await this.snapshotStore.getSnapshot(aggregateId);

if (snapshot) {

const eventsAfterSnapshot = await this.getEventsAfterVersion(

aggregateId,

snapshot.version

);

return [snapshot.event, ...eventsAfterSnapshot];

}

return super.getEvents(aggregateId);

}

}

Monitoring and Observability

Event-driven systems require comprehensive monitoring. Track event processing latency, projection lag, and system health:

typescript
class EventProcessingMonitor {

constructor(private metricsCollector: MetricsCollector) {}

async trackEventProcessing<T>(

eventType: string,

processor: () => Promise<T>

): Promise<T> {

const startTime = Date.now();

try {

const result = await processor();

this.metricsCollector.increment('events.processed', {

eventType,

status: 'success'

});

this.metricsCollector.timing('events.processing_time',

Date.now() - startTime,

{ eventType }

);

return result;

} catch (error) {

this.metricsCollector.increment('events.processed', {

eventType,

status: 'error'

});

throw error;

}

}

}

⚠️
WarningAlways implement proper error handling and retry mechanisms for event processing. Failed events should be sent to a dead letter queue for manual investigation.

Scaling Event Sourcing in Production Environments

Successful production deployment of event sourcing and CQRS requires addressing scalability, consistency, and operational complexity. Organizations implementing these patterns often see significant improvements in system auditability and developer productivity once properly established.

At PropTechUSA.ai, our event-sourced architecture has enabled us to provide real-time analytics, comprehensive audit trails, and flexible integration capabilities that traditional architectures struggle to deliver. Property managers can trace every action taken on their portfolios, while tenants enjoy responsive applications backed by optimized read models.

The separation of concerns achieved through CQRS allows teams to optimize read and write operations independently. Write-side performance focuses on business logic execution and event persistence, while read-side optimization centers on query performance and data presentation. This architectural flexibility proves invaluable as business requirements evolve and scale.

Event sourcing naturally provides the comprehensive audit trails required in regulated industries like property management. Every state change is preserved with full context, enabling compliance reporting, debugging complex business scenarios, and providing stakeholders with complete visibility into system operations.

Implementing event sourcing and CQRS requires significant architectural investment, but the long-term benefits in system flexibility, auditability, and scalability make it an attractive choice for complex business domains. Start with a bounded context that would benefit from comprehensive audit trails and temporal queries, then gradually expand the pattern as your team gains expertise.

Ready to implement event sourcing in your PropTech [platform](/saas-platform)? PropTechUSA.ai offers comprehensive architectural consulting and implementation services to help you build scalable, audit-friendly systems that grow with your business needs.

🚀 Ready to Build?

Let's discuss how we can help with your project.

Start Your Project →