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—...

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
1.1 When Event Sourcing is a strong fit
1.2 When Event Sourcing becomes a multiplier, not just a storage choice
1.3 When Event Sourcing is the wrong hammer
2. The decision test: what questions do you expect your system to answer in two years?
2.1 A concrete Java example: an append-only Event Store, an aggregate, and a projection
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>
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.
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.
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
3. Practical “fit signals” you can use in real projects
3.1 If your domain sounds like a ledger, you’re already halfway there
3.2 If you expect many read shapes, Event Sourcing reduces long-term coupling
3.3 If your team is small and the domain is simple, be suspicious of the hype
4. Image links you can embed in your Markdown article
5. Closing thought: treat Event Sourcing like a business feature, not a technical flex
Read more at : Event Sourcing Fits: When Should You Use It (and When You Shouldn’t)?







