Skip to main content

Command Palette

Search for a command to run...

Event Sourcing Fits: When Should You Use It (and When You Shouldn’t)?

This article explores when Event Sourcing is actually the right architectural choice, not as a buzzword but as a deliberate business decision. Instead of jumping straight into definitions, it walks through the real problems Event Sourcing solves—...

Published
13 min read
Event Sourcing Fits: When Should You Use It (and When You Shouldn’t)?
T

I am Tuanh.net. As of 2024, I have accumulated 8 years of experience in backend programming. I am delighted to connect and share my knowledge with everyone.

1. The quiet problem Event Sourcing actually solves

Most systems don’t fail because they “can’t store data.” They fail because they can’t explain data. Someone changes a salary, cancels an order, edits a bank account, or replays a movie payment, and six weeks later you’re staring at a database row thinking: who touched this, when, and why does it look like a haunted spreadsheet? Event Sourcing is a design that treats every meaningful change as an immutable event, stored in an append-only log, so the current state becomes a result of history rather than a fragile snapshot. Instead of persisting “Order = PAID,” you persist “OrderCreated,” “PaymentAuthorized,” “PaymentCaptured,” and you can rebuild the present by replaying the past. That single shift—state as a projection of facts—turns audit, debugging, recovery, and time-travel questions from “impossible” to “expensive but doable,” which is a massive upgrade in the real world.

1.1 When Event Sourcing is a strong fit

Event Sourcing shines when your domain values traceability as a first-class feature, not a nice-to-have bolted on with “updated_at” and prayers. If you are building anything with compliance pressure, financial correctness, accountability, or disputes, it’s often more valuable to know the sequence of truth than to store the latest truth. Typical examples include payments, wallets, accounting ledgers, inventory movements, HR changes (salary adjustments, contract updates, insurance contribution changes), and entitlement systems (leave balances, membership tiers, subscription state). In these domains, “how did we get here?” is not a debugging question; it’s a business question, and Event Sourcing answers it naturally because the system is the history.

1.2 When Event Sourcing becomes a multiplier, not just a storage choice

Event Sourcing becomes especially compelling when you also need multiple read views with different shapes and performance needs. The event log can feed projections optimized for search screens, dashboards, reporting, or real-time alerts, without you handcrafting a tangled web of triggers and fragile denormalized tables. If you’re already thinking about CQRS, streaming pipelines, or analytics fed from operational activity, Event Sourcing can reduce duplication because “publish facts once, build views many ways” becomes your default posture. It also helps when you need resilience and replayability: if a projection is wrong, you can fix the code and rebuild it from events, rather than writing scary one-off SQL migrations that feel like defusing a bomb in production.

1.3 When Event Sourcing is the wrong hammer

Event Sourcing is not “always better,” it’s “better when the problem pays for it.” If your domain is simple CRUD with little audit value, few invariants, and minimal dispute resolution—think a basic blog, a small catalog, a simple admin panel—Event Sourcing can feel like wearing a tuxedo to eat phở: technically allowed, socially confusing. It can also be a poor fit when your team cannot commit to the operational and conceptual discipline: designing events carefully, versioning schemas, handling eventual consistency, and building observability around asynchronous projections. If your system must guarantee immediate cross-aggregate consistency in a single transaction across many entities, you will either fight the model or rebuild a mini distributed database. Event Sourcing doesn’t ban strong consistency, but it forces you to be honest about where you truly need it.

2. The decision test: what questions do you expect your system to answer in two years?

A practical way to decide is to look beyond today’s endpoints and imagine tomorrow’s investigations. If you expect questions like “why did this employee’s insurance period change,” “who adjusted this payout,” “why was this order refunded,” “what was the state at 10:03 AM,” or “recompute balances with new rules from January 2026,” Event Sourcing has a habit of paying back its complexity with interest. If your future is mostly “list, edit, delete,” and the biggest historical question is “what’s the last modified time,” then a well-designed relational model with explicit audit tables might be the calmer, cheaper choice. The point is not ideology; it’s return on complexity.

2.1 A concrete Java example: an append-only Event Store, an aggregate, and a projection

To make this tangible, imagine a small “wallet” domain where correctness matters. A wallet has credits and debits, and you want a perfect trail that can be recomputed if rules change. We’ll implement three parts in Java: an EventStore that appends events with optimistic concurrency, a WalletAggregate that enforces invariants (for example, no overdraft), and a projection that builds a fast read model (wallet_balance) for APIs. The key idea is that we do not store “balance” as the source of truth; we store events like MoneyDeposited and MoneyWithdrawn, and balance is computed by replaying them.

// --- Domain events (facts). Keep them immutable. ---
import java.time.Instant;
import java.util.UUID;

public sealed interface WalletEvent permits MoneyDeposited, MoneyWithdrawn {
UUID walletId();
long amount(); // stored as minor unit, e.g., VND
Instant occurredAt();
String type();
}

public record MoneyDeposited(UUID walletId, long amount, Instant occurredAt) implements WalletEvent {
public String type() { return "MoneyDeposited"; }
}

public record MoneyWithdrawn(UUID walletId, long amount, Instant occurredAt) implements WalletEvent {
public String type() { return "MoneyWithdrawn"; }
}

// --- Aggregate: rebuilds state from events and enforces invariants on commands. ---
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;

public final class WalletAggregate {
private final UUID walletId;
private long balance;
private long version; // event stream version for optimistic concurrency

private final List<walletevent> newEvents = new ArrayList<>();

public WalletAggregate(UUID walletId) {
this.walletId = walletId;
}

public static WalletAggregate rehydrate(UUID walletId, List<walletevent> history, long startingVersion) {
WalletAggregate agg = new WalletAggregate(walletId);
agg.version = startingVersion;
for (WalletEvent e : history) {
agg.apply(e);
agg.version++; // each historical event increments version
}
return agg;
}

public void deposit(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be > 0");
newEvents.add(new MoneyDeposited(walletId, amount, Instant.now()));
}

public void withdraw(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be > 0");
long projected = balance - amount;
if (projected < 0) throw new IllegalStateException("insufficient funds");
newEvents.add(new MoneyWithdrawn(walletId, amount, Instant.now()));
}

public void apply(WalletEvent e) {
if (e instanceof MoneyDeposited d) balance += d.amount();
else if (e instanceof MoneyWithdrawn w) balance -= w.amount();
}

public List<walletevent> popNewEventsAndApply() {
for (WalletEvent e : newEvents) apply(e);
List<walletevent> out = List.copyOf(newEvents);
newEvents.clear();
return out;
}

public long balance() { return balance; }
public long expectedVersion() { return version; }
}

// --- Event store: append-only storage with optimistic concurrency. ---
// This example uses JDBC, but the pattern works with Postgres, MySQL, etc.
import javax.sql.DataSource;
import java.sql.;
import java.time.Instant;
import java.util.
;

public interface EventStore<e> {
List<e> load(UUID streamId);
long loadCurrentVersion(UUID streamId);
void append(UUID streamId, long expectedVersion, List<e> events);
}

public final class JdbcWalletEventStore implements EventStore<walletevent> {
private final DataSource ds;

public JdbcWalletEventStore(DataSource ds) {
this.ds = ds;
}

@Override
public List<walletevent> load(UUID walletId) {
String sql = """
SELECT event_type, amount, occurred_at
FROM wallet_events
WHERE wallet_id = ?
ORDER BY seq ASC
""";
List<walletevent> out = new ArrayList<>();
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setObject(1, walletId);
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
String type = rs.getString("event_type");
long amount = rs.getLong("amount");
Instant at = rs.getTimestamp("occurred_at").toInstant();
out.add(deserialize(walletId, type, amount, at));
}
}
return out;
} catch (SQLException ex) {
throw new RuntimeException(ex);
}
}

@Override
public long loadCurrentVersion(UUID walletId) {
String sql = "SELECT COALESCE(MAX(seq), 0) AS v FROM wallet_events WHERE wallet_id = ?";
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setObject(1, walletId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong("v");
}
} catch (SQLException ex) {
throw new RuntimeException(ex);
}
}

@Override
public void append(UUID walletId, long expectedVersion, List<walletevent> events) {
if (events.isEmpty()) return;

// Important: concurrency check + append must be atomic.
// One simple approach is to lock the stream row or rely on a unique constraint on (wallet_id, seq).
String insert = """
INSERT INTO wallet_events(wallet_id, seq, event_type, amount, occurred_at)
VALUES (?, ?, ?, ?, ?)
""";

try (Connection c = ds.getConnection()) {
c.setAutoCommit(false);

long current = loadCurrentVersionTx(c, walletId);
if (current != expectedVersion) {
c.rollback();
throw new IllegalStateException("concurrency conflict: expected=" + expectedVersion + " actual=" + current);
}

long nextSeq = current;
try (PreparedStatement ps = c.prepareStatement(insert)) {
for (WalletEvent e : events) {
nextSeq++;
ps.setObject(1, walletId);
ps.setLong(2, nextSeq);
ps.setString(3, e.type());
ps.setLong(4, e.amount());
ps.setTimestamp(5, Timestamp.from(e.occurredAt()));
ps.addBatch();
}
ps.executeBatch();
}

c.commit();
} catch (SQLException ex) {
throw new RuntimeException(ex);
}
}

private long loadCurrentVersionTx(Connection c, UUID walletId) throws SQLException {
String sql = "SELECT COALESCE(MAX(seq), 0) AS v FROM wallet_events WHERE wallet_id = ? FOR UPDATE";
try (PreparedStatement ps = c.prepareStatement(sql)) {
ps.setObject(1, walletId);
try (ResultSet rs = ps.executeQuery()) {
rs.next();
return rs.getLong("v");
}
}
}

private WalletEvent deserialize(UUID walletId, String type, long amount, Instant at) {
return switch (type) {
case "MoneyDeposited" -> new MoneyDeposited(walletId, amount, at);
case "MoneyWithdrawn" -> new MoneyWithdrawn(walletId, amount, at);
default -> throw new IllegalArgumentException("unknown event type: " + type);
};
}
}

// --- Application service: command handling that loads history, applies command, and appends new events. ---
import java.util.List;
import java.util.UUID;

public final class WalletService {
private final EventStore<walletevent> store;
private final WalletBalanceProjection projection; // read model updater

public WalletService(EventStore<walletevent> store, WalletBalanceProjection projection) {
this.store = store;
this.projection = projection;
}

public long deposit(UUID walletId, long amount) {
long currentVersion = store.loadCurrentVersion(walletId);
List<walletevent> history = store.load(walletId);

WalletAggregate agg = WalletAggregate.rehydrate(walletId, history, 0);
agg.deposit(amount);

List<walletevent> newEvents = agg.popNewEventsAndApply();
store.append(walletId, currentVersion, newEvents);

// In production you often publish to a bus and update projections async,
// but here we show sync to keep the example focused.
projection.apply(newEvents);

return agg.balance();
}

public long withdraw(UUID walletId, long amount) {
long currentVersion = store.loadCurrentVersion(walletId);
List<walletevent> history = store.load(walletId);

WalletAggregate agg = WalletAggregate.rehydrate(walletId, history, 0);
agg.withdraw(amount);

List<walletevent> newEvents = agg.popNewEventsAndApply();
store.append(walletId, currentVersion, newEvents);
projection.apply(newEvents);

return agg.balance();
}
}
</walletevent></walletevent></walletevent></walletevent></walletevent></walletevent>

// --- Projection: fast read model table. ---
import javax.sql.DataSource;
import java.sql.*;
import java.util.List;
import java.util.UUID;

public final class WalletBalanceProjection {
private final DataSource ds;

public WalletBalanceProjection(DataSource ds) {
this.ds = ds;
}

public void apply(List<walletevent> events) {
for (WalletEvent e : events) {
if (e instanceof MoneyDeposited d) upsert(d.walletId(), +d.amount());
else if (e instanceof MoneyWithdrawn w) upsert(w.walletId(), -w.amount());
}
}

private void upsert(UUID walletId, long delta) {
String sql = """
INSERT INTO wallet_balance(wallet_id, balance)
VALUES (?, ?)
ON CONFLICT (wallet_id) DO UPDATE SET balance = wallet_balance.balance + EXCLUDED.balance
""";
try (Connection c = ds.getConnection();
PreparedStatement ps = c.prepareStatement(sql)) {
ps.setObject(1, walletId);
ps.setLong(2, delta);
ps.executeUpdate();
} catch (SQLException ex) {
throw new RuntimeException(ex);
}
}
}
</walletevent>

Now, here’s what this example is really demonstrating, beyond the code. The WalletEvent types are your domain facts, and they are intentionally boring because boring facts are trustworthy facts; “MoneyWithdrawn 50,000 VND at 14:02” is a stronger building block than “balance = 120,000” because you can always recompute balance, but you can’t always reconstruct intent. The WalletAggregate is where business rules live, and the critical discipline is that it never “saves state”; it only decides which new events are allowed, based on state that was rebuilt from history. That’s why rehydrate exists: the aggregate becomes a pure function of past events plus the new command, and this makes it far easier to reason about correctness when the system grows.

The JdbcWalletEventStore.append(...) method shows the part people underestimate: concurrency. Event streams are effectively append-only logs, so you must prevent two writers from appending to the same wallet at the same logical version. In this example, we do a FOR UPDATE to lock the stream and verify expectedVersion, then we append new rows with sequential seq. That “expected vs actual” check is the simplest form of optimistic concurrency control: if two requests race, one wins, the other retries after reloading. Without this, your event log becomes a soap opera where two plotlines contradict each other.

Finally, the projection (wallet_balance) is the performance escape hatch. Your API does not want to replay 50,000 events on every request, and it shouldn’t. Instead, you continuously apply events to build a query-optimized view. In production, you typically update projections asynchronously from a message bus (Kafka, Redis Streams, etc.) so the write path stays fast and the read model becomes eventually consistent, which is usually fine for dashboards and many user screens. When you truly need “read-your-write,” you can either do a synchronous projection update (as shown), or you can read from the aggregate right after writing while the projection catches up. The point is that Event Sourcing gives you options, but it also forces you to name your consistency requirements instead of pretending they don’t exist.

2.2 The hidden costs you must budget for

Event Sourcing is a commitment to operational maturity. You must think about event versioning because schemas evolve; you must decide how to handle old events when the meaning changes; you must design idempotency and deduplication in projections because retries are not a bug, they are Tuesday. You will eventually want snapshots when streams get large, not because replay is impossible, but because replay is a tax you’ll pay often during incidents and rebuilds. You also need better observability: correlation IDs across command handling, event append, and projection application, otherwise your debugging experience becomes interpretive dance.

You also need to accept that not every query belongs in the write model. If you try to force Event Sourcing to behave like a single relational schema that answers every query with joins, you’ll feel pain. The healthier mindset is that events are the truth, projections are the convenience, and convenience can be rebuilt. Once your team internalizes that, the design becomes coherent; before that, it can feel like chaos with extra steps.

3. Practical “fit signals” you can use in real projects

3.1 If your domain sounds like a ledger, you’re already halfway there

If users argue about history, if auditors exist, if you reconcile numbers, if you need reversals rather than deletes, if “who changed it” is a core requirement, Event Sourcing tends to fit naturally. The earlier you adopt it in such domains, the less you rely on fragile audit hacks later.

3.2 If you expect many read shapes, Event Sourcing reduces long-term coupling

When you know you’ll need search views, recommendation feeds, reports, admin screens, and analytics, Event Sourcing makes “new view” a matter of subscribing and projecting rather than rewriting core transaction tables. That flexibility becomes very valuable when product requirements evolve faster than database migrations can safely keep up.

3.3 If your team is small and the domain is simple, be suspicious of the hype

When the business problem is straightforward, the best architecture is usually the one that keeps you shipping. Event Sourcing can be a great choice, but only if the system’s future will demand its strengths. Otherwise you’ll spend your time building infrastructure instead of value, like polishing a race car that only drives to the grocery store.

4. Image links you can embed in your Markdown article

Event Sourcing overview diagram
CQRS + Event Sourcing (conceptual)
Event-driven architecture visual

5. Closing thought: treat Event Sourcing like a business feature, not a technical flex

Event Sourcing is best viewed as a way to preserve truth, reduce regret, and make change safer. If your product is heading into a world where audits, disputes, rebuilds, and derived views are inevitable, the approach can feel like upgrading from “save game” to “full replay.” If your world is simpler, it may be wiser to keep a clean relational model and add targeted audit trails where they matter. Either choice can be “senior,” as long as it’s intentional—and not because a blog post made it sound cool (blog posts have never paid an on-call bill).

If you want, comment below what domain you’re building (payments, HR, orders, streaming subscriptions, anything) and what kind of “history questions” you expect users or admins to ask, and I’ll help you judge whether Event Sourcing is worth the complexity in your case.

Read more at : Event Sourcing Fits: When Should You Use It (and When You Shouldn’t)?

More from this blog

T

tuanh.net

540 posts

Are you ready to elevate your Java, OOP, Spring, and DevOps skills? Look no further!