Let's have an honest conversation about Event Sourcing. Not the "Event Sourcing is amazing" conference talk version. Not the "Event Sourcing is evil" rant version. The real version, where we acknowledge it's a powerful tool that's perfect for some problems and terrible for others.
After implementing Event Sourcing in systems processing millions of events daily and also watching teams burn months trying to force it where it doesn't belong, I've learned one thing: the answer to "Should I use Event Sourcing?" is always "It depends."
So let's dig into what it actually depends on.
First, Let's Be Clear About What Event Sourcing Actually Is
Event Sourcing means storing every state change as an immutable event rather than storing current state. Instead of updating a row in a database, you append an event like "UserEmailChanged" or "OrderShipped" to an event log. Current state is derived by replaying these events.
It's not just audit logging. It's not just publishing domain events. It's a fundamental architectural decision that affects every layer of your application.
When Event Sourcing Actually Shines (The Success Stories)
Before we talk about problems, let's acknowledge where Event Sourcing truly excels. These aren't theoretical benefits – these are real wins I've seen in production.
Financial Systems: The Natural Fit
In a payment processing system I worked on, Event Sourcing wasn't just helpful – it was essential. Every transaction naturally maps to events: "PaymentInitiated", "PaymentAuthorized", "PaymentCaptured", "PaymentRefunded". The business literally thinks in events.
// This feels natural and matches the domain perfectly
class Payment {
fun authorize(amount: Money, cardToken: String): List<Event> {
return when (state) {
PaymentState.INITIATED -> listOf(
PaymentAuthorized(
paymentId = id,
amount = amount,
authorizedAt = Instant.now(),
authorizationCode = generateAuthCode()
)
)
PaymentState.AUTHORIZED -> throw AlreadyAuthorizedException()
PaymentState.CAPTURED -> throw PaymentAlreadyCompletedException()
}
}
fun handle(event: PaymentAuthorized) {
state = PaymentState.AUTHORIZED
authorizedAmount = event.amount
authCode = event.authorizationCode
}
}
When the auditors come asking "Show me every action taken on payment XYZ-123", you literally hand them the event stream. When a customer disputes a charge, you can replay exactly what happened, in order, with timestamps. It's beautiful when it fits.
Collaborative Systems: Conflict Resolution Done Right
In a collaborative document editor (think Google Docs), Event Sourcing enables something magical: operational transformation. Users generate events like "InsertText", "DeleteRange", "FormatBold". When conflicts occur, you can:
- Reorder events based on vector clocks
- Transform operations to maintain consistency
- Show users exactly who changed what and when
- Implement "time travel" to see document history
Try implementing this with traditional CRUD. I'll wait.
Complex Business Workflows: Saga Paradise
When you have long-running business processes with compensating transactions, Event Sourcing with the Saga pattern is incredibly powerful:
@Saga
class OrderFulfillmentSaga {
@StartSaga
@SagaEventHandler
fun handle(event: OrderPlaced) {
// Reserve inventory
commandGateway.send(ReserveInventory(event.orderId, event.items))
}
@SagaEventHandler
fun handle(event: InventoryReserved) {
// Charge payment
commandGateway.send(ChargePayment(orderId, event.totalAmount))
}
@SagaEventHandler
fun handle(event: PaymentFailed) {
// Compensate by releasing inventory
commandGateway.send(ReleaseInventory(orderId))
}
@EndSaga
@SagaEventHandler
fun handle(event: OrderShipped) {
// Saga completed successfully
}
}
This is orders of magnitude cleaner than managing workflow state in database tables with status columns and timestamp fields.
Where Event Sourcing Becomes a Liability
Now let's talk about where Event Sourcing hurts more than it helps. These aren't edge cases – these are common scenarios where teams waste months fighting the pattern.
Simple CRUD Applications: The Complexity Tax
I watched a team implement Event Sourcing for a basic inventory management system. What should have been a simple "UPDATE products SET quantity = quantity - 1" became:
// What used to be one line of SQL
class InventoryItem {
fun decrementStock(quantity: Int): List<Event> {
if (currentStock < quantity) {
return listOf(StockDecrementFailed(itemId, quantity, currentStock))
}
return listOf(StockDecremented(itemId, quantity, Instant.now()))
}
fun handle(event: StockDecremented) {
currentStock -= event.quantity
lastModified = event.timestamp
}
}
// Plus event store, projections, read models, etc.
class InventoryProjection {
@EventHandler
fun on(event: StockDecremented) {
// Update read model
inventoryRepository.updateStock(event.itemId, -event.quantity)
}
}
// And don't forget handling eventual consistency
class InventoryQueryHandler {
fun getStock(itemId: String): StockLevel {
// Is this the latest? Who knows!
return readModelRepository.findById(itemId)
?: eventStore.replayEvents(itemId).calculateStock()
}
}
For what benefit? They didn't need audit trails. They didn't need time travel. They just needed to track inventory levels. Six months and three developers later, they rewrote it as a simple CRUD app in two weeks.
High-Frequency Updates: The Performance Nightmare
Consider a real-time gaming leaderboard. Players' scores update constantly. With Event Sourcing, every score update is an event:
// This generates thousands of events per second per player
PlayerScored(playerId: "123", points: 10, timestamp: "2025-01-18T10:00:01")
PlayerScored(playerId: "123", points: 5, timestamp: "2025-01-18T10:00:02")
PlayerScored(playerId: "123", points: 15, timestamp: "2025-01-18T10:00:03")
// ... thousands more
// To get current score, you replay ALL events
val currentScore = events
.filter { it.playerId == "123" }
.sumOf { it.points } // Processing thousands of events for one number
Yes, you can implement snapshots. Yes, you can optimize the projections. But why are you fighting the architecture? Just use a counter in Redis and call it a day.
GDPR and Data Privacy: The Immutability Paradox
"Delete my data" says the user. "But our events are immutable!" says Event Sourcing. Welcome to the GDPR nightmare.
The "solutions" are all compromises:
- Crypto-shredding: Encrypt personal data, delete keys. Hope you never need to debug those events.
- Event transformation: Rewrite history by filtering events. Breaks the fundamental promise of immutability.
- Tombstone events: Add "DataDeleted" events. The data is still there, you're just pretending it's not.
Each approach adds complexity and potential security risks. For many applications, it's simpler to just use a database with a DELETE statement.
The Decision Framework: A Practical Guide
Here's my battle-tested framework for deciding whether to use Event Sourcing:
Strong Indicators FOR Event Sourcing:
- Natural Event-Driven Domain: Your domain experts naturally describe the system in terms of events ("when the order is placed", "after payment is authorized")
- Audit Requirements: Legal or compliance requirements for complete, immutable audit trails (financial services, healthcare, government)
- Time Travel is Valuable: You need to reconstruct system state at any point in time for debugging, analysis, or compliance
- Complex Workflows: Long-running business processes with multiple steps and compensating transactions
- Collaborative Conflict Resolution: Multiple users modifying the same entities with need for conflict resolution
- Event-Driven Integration: Your system needs to publish events to other systems anyway
Strong Indicators AGAINST Event Sourcing:
- Simple CRUD Operations: Your app is mainly forms over data with basic create, read, update, delete operations
- High-Frequency Updates: The same values change constantly (counters, timestamps, status flags)
- Large Binary Data: Storing images, documents, or videos as events is usually a terrible idea
- Team Lacks Experience: Event Sourcing requires a mental model shift. Without experienced team members, you'll likely build it wrong
- Tight Deadlines: Event Sourcing takes 2-3x longer to implement initially. Not suitable for MVPs or proof-of-concepts
- GDPR/Privacy Concerns: If you need true data deletion (not just marking as deleted), Event Sourcing makes this much harder
The Pragmatic Middle Ground
Here's what I actually recommend for most teams: Start simple, evolve toward Event Sourcing if needed.
Level 1: Domain Events with CRUD
Keep your traditional database, but publish domain events for important state changes:
@Service
class OrderService {
@Transactional
fun placeOrder(order: Order): Order {
// Traditional database save
val savedOrder = orderRepository.save(order)
// Publish domain event for interested parties
eventPublisher.publish(
OrderPlaced(
orderId = savedOrder.id,
customerId = savedOrder.customerId,
total = savedOrder.total,
items = savedOrder.items
)
)
return savedOrder
}
}
You get event-driven integration and can add audit logging, but you're not fighting eventual consistency for simple queries.
Level 2: Audit Log Pattern
Store events alongside your regular data:
-- Your normal table
CREATE TABLE orders (
id UUID PRIMARY KEY,
customer_id UUID NOT NULL,
status VARCHAR(50) NOT NULL,
total DECIMAL(10,2) NOT NULL,
updated_at TIMESTAMP NOT NULL
);
-- Audit/event table
CREATE TABLE order_events (
id UUID PRIMARY KEY,
order_id UUID NOT NULL,
event_type VARCHAR(100) NOT NULL,
event_data JSONB NOT NULL,
occurred_at TIMESTAMP NOT NULL,
user_id UUID,
INDEX idx_order_events_order_id (order_id),
INDEX idx_order_events_occurred_at (occurred_at)
);
-- Best of both worlds: simple queries and event history
SELECT * FROM orders WHERE status = 'PENDING'; -- Fast, simple
SELECT * FROM order_events WHERE order_id = ?; -- Full history when needed
Level 3: Selective Event Sourcing
Use Event Sourcing only for aggregates that truly benefit from it:
// Event sourced for complex workflows
@Aggregate
class PaymentProcess {
// Full Event Sourcing with all its glory
}
// Simple CRUD for reference data
@Entity
class Product {
// Just a regular JPA entity
}
// Hybrid approach for orders
@Entity
class Order {
// State in database
// Important events in event store
}
This isn't architectural purity, but it's pragmatic and it works.
Migration Strategies: From CRUD to Event Sourcing
If you decide you need Event Sourcing later, here's how to migrate without stopping the world:
The Strangler Fig Pattern
- Start capturing events alongside your CRUD operations
- Build read models from events, compare with existing data
- Gradually switch reads to use event-sourced projections
- Finally, switch writes to append events only
- Keep the old system as a fallback for a while
The Bounded Context Approach
Start with Event Sourcing in new bounded contexts:
- New features use Event Sourcing if appropriate
- Existing features remain CRUD
- Use anti-corruption layers between contexts
- Migrate contexts one at a time if beneficial
Real Talk: The Human Cost
Beyond technical considerations, consider the human cost:
The Learning Curve is Real
Event Sourcing requires developers to think differently. Simple bugs become complex debugging sessions. "Why is the read model out of sync?" "Which event handler isn't idempotent?" "Why does replaying events give different results?"
The Bus Factor
If only one or two people on your team truly understand the Event Sourcing implementation, you're one resignation away from a maintenance nightmare. I've seen teams abandon working Event Sourcing systems simply because the architects left and nobody else could maintain it.
The Hiring Challenge
Finding developers with Event Sourcing experience is harder and more expensive than finding developers who know SQL. This is a real cost that compounds over time.
The Verdict: It's Not About Good or Bad
Event Sourcing isn't good or bad. It's a tool. Like any tool, it's excellent for some jobs and terrible for others. The problem isn't Event Sourcing itself – it's applying it without understanding the tradeoffs.
Use Event Sourcing when:
- Your domain naturally thinks in events
- You need perfect audit trails
- You have complex workflows with compensating transactions
- Time travel debugging would provide significant value
- Your team has the expertise to build and maintain it
Avoid Event Sourcing when:
- You're building simple CRUD applications
- You have high-frequency updates to the same values
- You need strict data privacy compliance
- Your team lacks Event Sourcing experience
- You're under tight deadlines
Consider the middle ground when:
- You want some benefits without full commitment
- You're not sure if you'll need Event Sourcing later
- Different parts of your system have different needs
- You want to experiment and learn
Final Thoughts: Choose Boring Technology (Until You Can't)
My default advice? Start with boring technology. Use PostgreSQL. Use CRUD. Use REST APIs. Add complexity only when the simple solution genuinely doesn't work.
But – and this is important – be ready to recognize when you've hit the limits of simple solutions. When you find yourself building elaborate audit trail mechanisms on top of CRUD, or when debugging production issues becomes impossible without knowing the sequence of changes, it might be time for Event Sourcing.
The key is matching the solution to the problem, not the other way around. Event Sourcing is powerful medicine – use it when you're actually sick, not because it's trendy.
And please, for the love of all that is holy, stop putting Event Sourcing on your resume if you've only used it in a todo app tutorial. We can tell.
For a practical example of Event Sourcing in production, check out our article on implementing Axon Framework without Axon Server, which shows both the power and complexity of real-world event sourcing.