Skip to content
Published on

Practical CQRS and Event Sourcing Implementation: Axon Framework and EventStoreDB 2026

Authors
  • Name
    Twitter
Practical CQRS and Event Sourcing Implementation: Axon Framework and EventStoreDB 2026

Overview

CQRS (Command Query Responsibility Segregation) and Event Sourcing are architectural patterns that separate writes and reads while preserving the complete history of state changes in systems dealing with complex domains. While the concepts themselves are simple, applying them in real production environments brings a flood of unexpected problems such as event schema evolution, projection rebuilding, snapshot strategies, and failure recovery.

This post focuses on Axon Framework 4.x, a leading CQRS/ES framework in the JVM ecosystem, and EventStoreDB (currently rebranded as KurrentDB), a dedicated event store. We cover everything from concept explanations to production code and operational troubleshooting. Event-driven governance and Saga pattern trade-offs are covered in separate posts, so this one concentrates on the CQRS and Event Sourcing implementation itself.

CQRS Core Concepts

Fundamental Differences Between Traditional CRUD and CQRS

In traditional CRUD architecture, a single domain model handles both writing and reading. Taking an order service as an example, the Order entity simultaneously performs write logic like order creation, status changes, and cancellation, as well as read logic like order list and detail queries. This is fine when data is small and the domain is simple, but as the system grows more complex, it becomes increasingly difficult to optimize both read performance and write integrity in a single model.

CQRS solves this problem structurally. The command side (Write Side) focuses on validating domain invariants and publishing events, while the query side (Read Side) provides read models optimized for users.

AspectTraditional CRUDCQRS
ModelSingle model (shared read/write)Separate command model + query model
Data StoreSingle DBWrite DB + Read DB (can be separated)
ScalabilityRead/write scale at same rateRead/write scale independently
ComplexityLow (at start)High (initial learning curve)
ConsistencyStrong consistencyEventual Consistency
Audit LogRequires separate implementationAutomatically available with Event Sourcing
Suitable ScenariosSimple CRUD appsComplex domains with large read/write ratio differences

When to Apply CQRS

CQRS is not suitable for every system. It is worth considering adoption if three or more of the following conditions apply:

  • Read traffic is 10x or more than write traffic
  • Read and write require different data models (e.g., normalized for writes, denormalized for reads)
  • Domain logic is complex enough to use DDD Aggregate patterns
  • Complete history of state changes is a business requirement (finance, healthcare, logistics)
  • Multiple teams consume the same data from different perspectives

Conversely, applying CQRS to simple CRUD apps or low-traffic internal admin tools is over-engineering.

Event Sourcing Principles

State Storage vs Event Storage

The traditional approach stores the current state in the DB. If an order status is "shipping", the status column in the orders table is updated to SHIPPING. Previous states are lost.

Event Sourcing stores the events themselves that caused state changes. The current state of an order is derived by replaying events like OrderCreated, PaymentCompleted, and ShippingStarted in sequence. Events are immutable, so they are never deleted or modified.

# Traditional State Storage
orders table: id=123, status=SHIPPING, total=50000

# Event Sourcing
Event stream [order-123]:
  1. OrderCreated    (total=50000, items=[...])
  2. PaymentCompleted(method=CARD, amount=50000)
  3. ShippingStarted (trackingNo=KR123456)

Current state = fold(events) => Order(status=SHIPPING, total=50000)

Benefits and Costs of Event Sourcing

The benefits are clear. You automatically get a complete audit log, time travel queries become possible, and when debugging you can trace exactly which event caused the problem. Especially in the financial domain, Event Sourcing is practically mandatory for regulatory compliance.

There are costs as well. As event streams grow longer, Aggregate load time increases (mitigated with snapshots), event schema changes become tricky (solved with upcasting), and the entire team must understand the eventual consistency model. Concrete solutions for these three issues are covered later.

Axon Framework Implementation

Axon Framework Architecture Overview

Axon Framework is a framework for implementing DDD, CQRS, and Event Sourcing on the JVM. The key components are:

  • Command Bus: Routes commands to appropriate handlers. Use DisruptorCommandBus when high performance is needed.
  • Event Bus / Event Store: Publishes and stores events. Using Axon Server handles both the distributed event bus and event store at once.
  • Query Bus: Routes queries to appropriate handlers.
  • Aggregate: The core unit of the command model that protects domain invariants.
  • Saga: Manages long-running transactions.

Implementing Aggregates and Command Handlers

Let us implement an order domain in Kotlin. Command handlers and event sourcing handlers are placed together inside the Aggregate class.

// Command definitions
data class CreateOrderCommand(
    @TargetAggregateIdentifier
    val orderId: String,
    val customerId: String,
    val items: List<OrderItem>,
    val totalAmount: Long
)

data class ConfirmPaymentCommand(
    @TargetAggregateIdentifier
    val orderId: String,
    val paymentMethod: String,
    val transactionId: String
)

data class CancelOrderCommand(
    @TargetAggregateIdentifier
    val orderId: String,
    val reason: String
)

// Event definitions
data class OrderCreatedEvent(
    val orderId: String,
    val customerId: String,
    val items: List<OrderItem>,
    val totalAmount: Long,
    val createdAt: Instant = Instant.now()
)

data class PaymentConfirmedEvent(
    val orderId: String,
    val paymentMethod: String,
    val transactionId: String,
    val confirmedAt: Instant = Instant.now()
)

data class OrderCancelledEvent(
    val orderId: String,
    val reason: String,
    val cancelledAt: Instant = Instant.now()
)
// Aggregate implementation
@Aggregate
class OrderAggregate {

    @AggregateIdentifier
    private lateinit var orderId: String
    private lateinit var customerId: String
    private var totalAmount: Long = 0
    private var status: OrderStatus = OrderStatus.DRAFT
    private var items: List<OrderItem> = emptyList()

    // Default constructor (used by Axon framework via reflection)
    constructor()

    // Creation command handler - creates a new Aggregate instance
    @CommandHandler
    constructor(cmd: CreateOrderCommand) {
        require(cmd.items.isNotEmpty()) { "Order items cannot be empty" }
        require(cmd.totalAmount > 0) { "Order amount must be greater than 0" }

        AggregateLifecycle.apply(
            OrderCreatedEvent(
                orderId = cmd.orderId,
                customerId = cmd.customerId,
                items = cmd.items,
                totalAmount = cmd.totalAmount
            )
        )
    }

    // Payment confirmation command handler
    @CommandHandler
    fun handle(cmd: ConfirmPaymentCommand) {
        check(status == OrderStatus.CREATED) {
            "Payment confirmation is only possible in CREATED status. Current status: $status"
        }
        AggregateLifecycle.apply(
            PaymentConfirmedEvent(
                orderId = cmd.orderId,
                paymentMethod = cmd.paymentMethod,
                transactionId = cmd.transactionId
            )
        )
    }

    // Order cancellation command handler
    @CommandHandler
    fun handle(cmd: CancelOrderCommand) {
        check(status != OrderStatus.CANCELLED) {
            "An already cancelled order cannot be cancelled again"
        }
        check(status != OrderStatus.SHIPPED) {
            "An order that has started shipping cannot be cancelled"
        }
        AggregateLifecycle.apply(
            OrderCancelledEvent(
                orderId = cmd.orderId,
                reason = cmd.reason
            )
        )
    }

    // Event sourcing handlers - update state (purely, without side effects)
    @EventSourcingHandler
    fun on(event: OrderCreatedEvent) {
        orderId = event.orderId
        customerId = event.customerId
        items = event.items
        totalAmount = event.totalAmount
        status = OrderStatus.CREATED
    }

    @EventSourcingHandler
    fun on(event: PaymentConfirmedEvent) {
        status = OrderStatus.PAID
    }

    @EventSourcingHandler
    fun on(event: OrderCancelledEvent) {
        status = OrderStatus.CANCELLED
    }
}

enum class OrderStatus {
    DRAFT, CREATED, PAID, SHIPPED, DELIVERED, CANCELLED
}

Two key principles must be noted in the code above. First, @CommandHandler only validates business rules and publishes events. It does not directly change state. Second, @EventSourcingHandler only updates state based on events. It does not include validation logic. If this separation breaks, unexpected errors occur during event replay.

Axon Framework vs Hand-Rolled Implementation

AspectAxon FrameworkHand-rolled
Learning CurveRelatively gentle (annotation-based)Must understand all infrastructure directly
Command RoutingAutomatic (Command Bus)Must implement routing manually
Event StoreBuilt-in via Axon Server or JPAMust design tables + serialization manually
SnapshotsEnabled with a single config lineMust implement + restoration logic manually
UpcastingBuilt-in Upcaster chainMust implement transformation pipeline
Distributed EnvAxon Server supports clusteringRequires separate infra (Kafka, etc.)
TestingProvides FixtureConfigurationMust configure test infrastructure manually
FlexibilityConstrained by framework conventionsFull freedom
Team HiringLimited Axon-experienced developersCan leverage general Java/Kotlin developers

Axon Framework significantly speeds up initial implementation, but if used without understanding the internal workings, debugging at the operational stage becomes extremely difficult. Before choosing the framework, ensure the team sufficiently understands Event Sourcing principles.

Using EventStoreDB

What is EventStoreDB

EventStoreDB (rebranded to KurrentDB in 2024) is a dedicated database designed for Event Sourcing. While you can implement an event store on top of general-purpose DBs like PostgreSQL or MongoDB, EventStoreDB natively supports event stream reading/writing, projections, and subscriptions.

It provides gRPC-based clients and supports multiple languages including .NET, Java, Node.js, Python, and Rust. Optimistic Concurrency Control is built-in, enabling detection of conflicts when concurrent writes occur on the same stream.

Event Store Implementation Comparison

AspectEventStoreDB (KurrentDB)Axon ServerMarten (.NET)PostgreSQL Hand-rolled
TypeDedicated Event DBDedicated Event DB + Message Router.NET library (PostgreSQL-based)Custom implementation on RDBMS
Language SupportMulti-language (gRPC clients)JVM-centric.NET onlyNo limitation
ProjectionsBuilt-in server-side JS projectionsClient-side projectionsInline + async projectionsCustom implementation
SubscriptionsCatch-up, Persistent subscriptionsTracking Event ProcessorDaemon-based async projectionsPolling or CDC
ClusteringLeader-follower clusterEnterprise editionLeverages PostgreSQL clusteringLeverages PostgreSQL HA
LicenseServer-Side Public LicenseCommunity / CommercialMITN/A
Ops DifficultyMediumLow (managed service available)Low (PostgreSQL ops)High (everything manual)

Using EventStoreDB with Python

Here is an example of writing/reading events using the EventStoreDB Python gRPC client (esdbclient).

import json
import uuid
from datetime import datetime, timezone
from esdbclient import EventStoreDBClient, NewEvent, StreamState

# Connect to EventStoreDB
client = EventStoreDBClient(uri="esdb://localhost:2113?tls=false")

# Create events and append to stream
stream_name = f"order-{uuid.uuid4()}"

events = [
    NewEvent(
        type="OrderCreated",
        data=json.dumps({
            "orderId": stream_name.split("-", 1)[1],
            "customerId": "customer-001",
            "items": [
                {"productId": "prod-a", "quantity": 2, "price": 15000},
                {"productId": "prod-b", "quantity": 1, "price": 20000},
            ],
            "totalAmount": 50000,
            "createdAt": datetime.now(timezone.utc).isoformat(),
        }).encode("utf-8"),
        metadata=json.dumps({"correlationId": str(uuid.uuid4())}).encode("utf-8"),
        content_type="application/json",
    ),
    NewEvent(
        type="PaymentConfirmed",
        data=json.dumps({
            "paymentMethod": "CARD",
            "transactionId": f"txn-{uuid.uuid4()}",
            "confirmedAt": datetime.now(timezone.utc).isoformat(),
        }).encode("utf-8"),
        content_type="application/json",
    ),
]

# Write only when stream does not yet exist (optimistic concurrency control)
commit_position = client.append_to_stream(
    stream_name=stream_name,
    current_version=StreamState.NO_STREAM,
    events=events,
)
print(f"Events recorded. Commit position: {commit_position}")

# Read events from stream
recorded_events = client.get_stream(stream_name)
for event in recorded_events:
    data = json.loads(event.data.decode("utf-8"))
    print(f"[{event.type}] stream_position={event.stream_position}, data={data}")

In the example above, StreamState.NO_STREAM allows writing only when the stream does not exist. If the stream already exists, a WrongExpectedVersionError is raised. This is EventStoreDB's optimistic concurrency control. When appending events to an existing stream, you must pass the stream_position of the last read event as the current_version.

Using EventStoreDB with TypeScript

In the Node.js/TypeScript environment, the @eventstore/db-client package is used.

import { EventStoreDBClient, jsonEvent, NO_STREAM, JSONEventType } from '@eventstore/db-client'

// Event type definitions
interface OrderCreated extends JSONEventType {
  type: 'OrderCreated'
  data: {
    orderId: string
    customerId: string
    items: Array<{ productId: string; quantity: number; price: number }>
    totalAmount: number
  }
}

interface PaymentConfirmed extends JSONEventType {
  type: 'PaymentConfirmed'
  data: {
    paymentMethod: string
    transactionId: string
  }
}

// Create client (use as singleton)
const client = EventStoreDBClient.connectionString('esdb://localhost:2113?tls=false')

async function appendAndReadEvents(): Promise<void> {
  const streamName = `order-${crypto.randomUUID()}`

  // Append events
  const orderCreated = jsonEvent<OrderCreated>({
    type: 'OrderCreated',
    data: {
      orderId: streamName.replace('order-', ''),
      customerId: 'customer-001',
      items: [{ productId: 'prod-a', quantity: 2, price: 15000 }],
      totalAmount: 30000,
    },
  })

  const paymentConfirmed = jsonEvent<PaymentConfirmed>({
    type: 'PaymentConfirmed',
    data: {
      paymentMethod: 'CARD',
      transactionId: `txn-${crypto.randomUUID()}`,
    },
  })

  // Optimistic concurrency control: write only when stream does not exist
  await client.appendToStream(streamName, [orderCreated, paymentConfirmed], {
    expectedRevision: NO_STREAM,
  })

  // Read events from stream
  const events = client.readStream(streamName)
  for await (const resolvedEvent of events) {
    console.log(`[${resolvedEvent.event?.type}]`, JSON.stringify(resolvedEvent.event?.data))
  }
}

appendAndReadEvents().catch(console.error)

Projections and Read Models

What Are Projections

In an Event Sourcing system, event streams are optimized for writing. When a user requests "show me my order list sorted by most recent", you cannot replay all events to build the current state. A Projection is a process that consumes events and builds a separate view (Read Model) optimized for reading.

In Axon Framework, Event Handlers serve this role. Methods annotated with @EventHandler receive events and update the read model (typically a table in an RDBMS).

// Read model entity
@Entity
@Table(name = "order_summary")
data class OrderSummaryEntity(
    @Id
    val orderId: String,
    val customerId: String,
    val totalAmount: Long,
    val status: String,
    val itemCount: Int,
    val createdAt: Instant,
    var updatedAt: Instant = Instant.now()
)

// Projection (Event Handler)
@Component
@ProcessingGroup("order-summary")
class OrderSummaryProjection(
    private val repository: OrderSummaryRepository
) {

    @EventHandler
    fun on(event: OrderCreatedEvent) {
        val summary = OrderSummaryEntity(
            orderId = event.orderId,
            customerId = event.customerId,
            totalAmount = event.totalAmount,
            status = "CREATED",
            itemCount = event.items.size,
            createdAt = event.createdAt
        )
        repository.save(summary)
    }

    @EventHandler
    fun on(event: PaymentConfirmedEvent) {
        repository.findById(event.orderId).ifPresent { summary ->
            repository.save(summary.copy(
                status = "PAID",
                updatedAt = Instant.now()
            ))
        }
    }

    @EventHandler
    fun on(event: OrderCancelledEvent) {
        repository.findById(event.orderId).ifPresent { summary ->
            repository.save(summary.copy(
                status = "CANCELLED",
                updatedAt = Instant.now()
            ))
        }
    }

    // Reset handler to clean up existing data during replay
    @ResetHandler
    fun onReset() {
        repository.deleteAll()
    }
}

// Query handler
@Component
class OrderQueryHandler(
    private val repository: OrderSummaryRepository
) {

    @QueryHandler
    fun handle(query: FindOrderByIdQuery): OrderSummaryEntity? {
        return repository.findById(query.orderId).orElse(null)
    }

    @QueryHandler
    fun handle(query: FindOrdersByCustomerQuery): List<OrderSummaryEntity> {
        return repository.findByCustomerIdOrderByCreatedAtDesc(query.customerId)
    }
}

Projection Rebuilding (Replay)

The greatest advantage of projections is that they can be rebuilt from scratch at any time. When the read model schema changes, or data is corrupted due to a bug, you simply replay events from the beginning.

In Axon Framework, resetting the TrackingEventProcessor triggers projection rebuilding. The @ResetHandler runs first to clean up existing data, then all events are replayed in order.

When rebuilding projections in production, the following must be considered:

  1. Rebuild Time: With millions of events, rebuilding can take several hours. A Blue-Green approach is recommended where you rebuild on a separate instance and switch traffic after completion.
  2. Concurrent Reads: The existing read model must continue serving during rebuilding. Proceed with parallel rebuilding using a new processing group name.
  3. Resource Usage: DB load spikes dramatically during mass replay. Batch size adjustment and rate limiting are necessary.

Snapshot Strategies

Why Snapshots Are Needed

To load an Aggregate in Event Sourcing, all events in the stream must be replayed from the beginning. With 10 events this is fine, but with over 10,000 events, load time becomes noticeably slow. A snapshot captures the Aggregate state at a specific point in time, and then only subsequent events need to be replayed.

Configuring Snapshots in Axon Framework

In Axon Framework, snapshot triggers can be easily configured through the configuration file.

# application.yml - Axon snapshot configuration
axon:
  aggregate:
    order:
      snapshot-trigger:
        type: event-count
        threshold: 100 # Create snapshot every 100 events

This can also be done with Java/Kotlin configuration.

@Configuration
class AxonSnapshotConfig {

    @Bean
    fun snapshotTriggerDefinition(snapshotter: Snapshotter): SnapshotTriggerDefinition {
        // Create snapshot every 50 events
        return EventCountSnapshotTriggerDefinition(snapshotter, 50)
    }
}

Operational Considerations for Snapshots

  • When Aggregate structure changes: Adding/removing fields in the Aggregate class can cause deserialization failures for existing snapshots. In this case, existing snapshots must be deleted and rebuilt from events only.
  • Tuning snapshot frequency: Creating them too often increases storage costs, while too infrequent means longer load times. Generally, an interval of 50 to 500 events is appropriate.
  • Business boundary-based snapshots: Instead of event-count-based, creating snapshots based on business events is also a good strategy. For example, taking snapshots at month-end settlement completion or at specific state transitions.

Event Version Management (Upcasting)

The Inevitability of Event Schema Evolution

As long as a system is alive, event schemas will inevitably change. You might need to add a couponCode field to OrderCreatedEvent, or change the type of totalAmount from Long to BigDecimal. With a traditional RDBMS, an ALTER TABLE would suffice, but in Event Sourcing, already-stored events cannot be modified.

Upcasting is a technique that transforms events of previous versions into a form that the current code can understand when reading them.

Implementing Upcasting in Axon Framework

First, mark the version on event classes with the @Revision annotation.

// V1: Initial version
@Revision("1.0")
data class OrderCreatedEvent(
    val orderId: String,
    val customerId: String,
    val items: List<OrderItem>,
    val totalAmount: Long,
    val createdAt: Instant = Instant.now()
)

// V2: Added couponCode field, added currency field
@Revision("2.0")
data class OrderCreatedEvent(
    val orderId: String,
    val customerId: String,
    val items: List<OrderItem>,
    val totalAmount: Long,
    val currency: String = "KRW",
    val couponCode: String? = null,
    val createdAt: Instant = Instant.now()
)

// Upcaster implementation: V1 -> V2
class OrderCreatedEventUpcaster : SingleEventUpcaster() {

    override fun canUpcast(intermediateRepresentation: IntermediateEventRepresentation): Boolean {
        return intermediateRepresentation.type.name == OrderCreatedEvent::class.qualifiedName
            && intermediateRepresentation.type.revision == "1.0"
    }

    override fun doUpcast(intermediateRepresentation: IntermediateEventRepresentation): IntermediateEventRepresentation {
        return intermediateRepresentation.upcast(
            SimpleSerializedType(
                OrderCreatedEvent::class.qualifiedName,
                "2.0"  // Target revision
            ),
            JsonNode::class.java
        ) { jsonNode ->
            // Add default values for fields missing in V1 events
            val objectNode = jsonNode as ObjectNode
            objectNode.put("currency", "KRW")
            objectNode.putNull("couponCode")
            objectNode
        }
    }
}

// Upcaster registration
@Configuration
class UpcasterConfig {

    @Bean
    fun orderUpcasterChain(): JpaEventStorageEngine {
        // Upcasters are configured as a chain - executed in V1->V2->V3 order
        return JpaEventStorageEngine.builder()
            .upcasterChain(DefaultUpcasterChain(listOf(
                OrderCreatedEventUpcaster(),
                // Add V2->V3 Upcaster later
            )))
            .build()
    }
}

Event Version Management Principles

  1. Always prioritize backward compatibility: When possible, give new fields default values and do not delete existing fields.
  2. Do not change semantic meaning: Do not change the meaning of the same field name. If amount was originally the pre-tax amount, do not change its meaning to post-tax. Add a new amountAfterTax field instead.
  3. Test upcaster chains: As upcasters accumulate, integration tests must verify that transformations from V1 to the latest version work correctly.
  4. Document revision management: Record what changed in each revision and why, in code comments or ADRs (Architecture Decision Records).

Troubleshooting

Common Problems and Solutions

1. Event Handler Ordering Issues

When multiple @EventHandler instances subscribe to the same event, execution order is not guaranteed, which can cause projection data inconsistency. In Axon Framework, separate processing groups with @ProcessingGroup and specify order within each group with the @Order annotation.

2. OOM (Out of Memory) During Projection Rebuilding

Replaying millions of events at once exhausts memory. Reduce the batchSize of TrackingEventProcessor and control replay speed.

axon:
  eventhandling:
    processors:
      order-summary:
        mode: tracking
        batch-size: 256 # Number of events to process at once
        thread-count: 2 # Number of parallel processing threads
        token-claim-interval: 5000 # Token renewal interval (ms)

3. Serializer Mismatch

Axon uses XStream XML serializer by default, but many teams switch to Jackson JSON. If existing events are stored in XML and you switch the serializer to JSON, deserialization fails. A multi-serializer configuration is needed during the transition.

4. Aggregate Load Timeout

Aggregates with extremely many events (tens of thousands or more) can take several seconds to load. Shortening the snapshot interval or separating Aggregate responsibilities to reduce event count is the fundamental solution.

5. Duplicate Event Processing

Network failures can cause duplicate event delivery. Event handlers must be implemented idempotently. In projections, add logic to detect duplicates using event IDs or sequence numbers.

Failure Cases

Case 1: Putting Everything in a Single Aggregate

An e-commerce team put order-payment-shipping-reviews all into a single OrderAggregate. It was simple to implement initially, but as review events were included, events per order reached hundreds. Aggregate load time started exceeding 3 seconds, and even applying snapshots did not fundamentally solve the problem as the Aggregate serialization size became bloated.

Resolution: Separated order, payment, shipping, and reviews into individual Aggregates. Connections between Aggregates were handled with Sagas. After separation, event count per Aggregate was maintained under 10.

Case 2: Querying with Events Only Without Projections

A team new to Event Sourcing decided "all data is in events so projections are unnecessary" and calculated current state by replaying events on every query. It worked when data was small, but the list query API response time grew to several seconds, causing user complaints to surge.

Resolution: Created read-only projection tables and changed event handlers to update them asynchronously. The list query API response time dropped from 200ms to 5ms.

Case 3: Changing Event Schema Without Upcasting

A field name was changed from amount to totalAmount without implementing an upcaster. Replay of existing events failed, leaving specific Aggregates in an unloadable state. A risky patch of directly modifying events in the production DB had to be applied.

Resolution: Implemented an upcaster and included compatibility tests with previous versions in CI for all event schema changes.

Operational Checklist

Review the following items before deploying CQRS + Event Sourcing to production.

Design Phase

  • Are Aggregate boundaries properly defined (no single Aggregate with too many responsibilities)?
  • Are event names clearly defined with past tense verbs (Created, Updated, Cancelled)?
  • Do command handlers contain only business validation logic, and event sourcing handlers contain only state updates?
  • Is at least one projection implemented, and do read APIs use projection tables?

Event Management

  • Do event classes have the @Revision annotation?
  • Are upcasters implemented for event schema changes?
  • Is the event serialization format unified (JSON recommended)?
  • Is an event size limit configured (under 1MB recommended)?

Performance

  • Is a snapshot strategy established (event-count-based or business-event-based)?
  • Is the average event count per Aggregate being monitored?
  • Has projection rebuild time been measured, and is it within acceptable bounds?
  • Is there a Blue-Green switch procedure for projection rebuilding?

Incident Response

  • Are event handlers implemented idempotently?
  • Is a Dead Letter Queue configured and being monitored?
  • Is projection lag being monitored in real-time?
  • Are alerts triggered on Aggregate load failures?
  • Have event store backup and restore procedures been verified?

Team Competency

  • Does the entire team understand the concept of Eventual Consistency?
  • Were domain events derived through Event Storming workshops?
  • Does the team know how to debug Event Sourcing (querying event streams, replaying)?

Conclusion

CQRS and Event Sourcing are powerful tools for systems dealing with complex domains, but the adoption cost is not small. The decision to adopt should consider the team's capabilities, domain complexity, and audit requirements comprehensively.

To summarize the key principles:

  1. Keep Aggregates small: Design each Aggregate to protect a single invariant.
  2. Events are immutable: Never modify stored events. Schema changes must always be handled through upcasters.
  3. Projections are disposable: They must be rebuildable from scratch at any time. Do not put source data in projections.
  4. Idempotency is mandatory: Event handlers must guarantee the same result even when processing the same event multiple times.
  5. Adopt incrementally: Start with the most complex bounded context, not the entire system.

Axon Framework is a mature framework that structurally supports all of this, and EventStoreDB is a powerful choice as a storage specialized for Event Sourcing. The two can be used together or independently. What matters is not the tools, but whether the entire team understands Event Sourcing principles.

References