- Authors
- Name

- Overview
- CQRS Core Concepts
- Event Sourcing Principles
- Axon Framework Implementation
- Using EventStoreDB
- Projections and Read Models
- Snapshot Strategies
- Event Version Management (Upcasting)
- Troubleshooting
- Failure Cases
- Operational Checklist
- Conclusion
- References
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.
| Aspect | Traditional CRUD | CQRS |
|---|---|---|
| Model | Single model (shared read/write) | Separate command model + query model |
| Data Store | Single DB | Write DB + Read DB (can be separated) |
| Scalability | Read/write scale at same rate | Read/write scale independently |
| Complexity | Low (at start) | High (initial learning curve) |
| Consistency | Strong consistency | Eventual Consistency |
| Audit Log | Requires separate implementation | Automatically available with Event Sourcing |
| Suitable Scenarios | Simple CRUD apps | Complex 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
| Aspect | Axon Framework | Hand-rolled |
|---|---|---|
| Learning Curve | Relatively gentle (annotation-based) | Must understand all infrastructure directly |
| Command Routing | Automatic (Command Bus) | Must implement routing manually |
| Event Store | Built-in via Axon Server or JPA | Must design tables + serialization manually |
| Snapshots | Enabled with a single config line | Must implement + restoration logic manually |
| Upcasting | Built-in Upcaster chain | Must implement transformation pipeline |
| Distributed Env | Axon Server supports clustering | Requires separate infra (Kafka, etc.) |
| Testing | Provides FixtureConfiguration | Must configure test infrastructure manually |
| Flexibility | Constrained by framework conventions | Full freedom |
| Team Hiring | Limited Axon-experienced developers | Can 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
| Aspect | EventStoreDB (KurrentDB) | Axon Server | Marten (.NET) | PostgreSQL Hand-rolled |
|---|---|---|---|---|
| Type | Dedicated Event DB | Dedicated Event DB + Message Router | .NET library (PostgreSQL-based) | Custom implementation on RDBMS |
| Language Support | Multi-language (gRPC clients) | JVM-centric | .NET only | No limitation |
| Projections | Built-in server-side JS projections | Client-side projections | Inline + async projections | Custom implementation |
| Subscriptions | Catch-up, Persistent subscriptions | Tracking Event Processor | Daemon-based async projections | Polling or CDC |
| Clustering | Leader-follower cluster | Enterprise edition | Leverages PostgreSQL clustering | Leverages PostgreSQL HA |
| License | Server-Side Public License | Community / Commercial | MIT | N/A |
| Ops Difficulty | Medium | Low (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:
- 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.
- Concurrent Reads: The existing read model must continue serving during rebuilding. Proceed with parallel rebuilding using a new processing group name.
- 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
- Always prioritize backward compatibility: When possible, give new fields default values and do not delete existing fields.
- Do not change semantic meaning: Do not change the meaning of the same field name. If
amountwas originally the pre-tax amount, do not change its meaning to post-tax. Add a newamountAfterTaxfield instead. - Test upcaster chains: As upcasters accumulate, integration tests must verify that transformations from V1 to the latest version work correctly.
- 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
@Revisionannotation? - 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:
- Keep Aggregates small: Design each Aggregate to protect a single invariant.
- Events are immutable: Never modify stored events. Schema changes must always be handled through upcasters.
- Projections are disposable: They must be rebuildable from scratch at any time. Do not put source data in projections.
- Idempotency is mandatory: Event handlers must guarantee the same result even when processing the same event multiple times.
- 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
- Axon Framework Documentation - Command Handlers
- Axon Framework Documentation - Event Versioning & Upcasting
- Axon Framework Documentation - Event Snapshots
- EventStoreDB (KurrentDB) Documentation
- esdbclient - Python gRPC Client for EventStoreDB (PyPI)
- @eventstore/db-client - Node.js Client (npm)
- Microsoft Azure Architecture Center - CQRS Pattern
- Microsoft Azure Architecture Center - Event Sourcing Pattern
- Baeldung - A Guide to the Axon Framework