Event Sourcing (Building an Impervious Audit Trail)
In a traditional application, the database simply stores the Current State of an entity. If a user changes their shipping address, your application runs an UPDATE SQL command. But because you literally overwrote the old database row, the previous address is gone forever. If you need to know why a package was sent to the wrong place last Tuesday, you have absolutely zero historical proof.
Event Sourcing completely changes this architecture. Instead of randomly altering the "Current State", you securely store every single action (Event) that has ever organically happened to the data in an append-only log.
To figure out the "Current State", you simply calculate it on-the-fly by chronologically replaying all the historical events from the beginning of time.
1. How Event Sourcing Works (The Bank Account)
The easiest way to physically understand Event Sourcing is by looking at a standard Bank Account. Banks never just store your current "Balance". Standard accounting prohibits destroying history. Instead, they strictly store a Ledger containing every transaction (Event) you've ever legitimately made.
ACCOUNT_OPENED(+ $0)MONEY_DEPOSITED(+ $1000)BILL_PAID(- $100)- Calculated Current State: $900
If someone aggressively disputes a charge, the bank has a mathematically perfect, legally unalterable Audit Trail.
2. Architecture Diagram (Event Sourcing Flow)
3. Architectural Code Example
Here is an amazingly readable Node.js implementation of an Event-Sourced system. Notice structurally how we NEVER run an UPDATE or DELETE database command. We purely append immutable events to an array, and dynamically rebuild the object completely from scratch on read.
const express = require("express");
const app = express();
app.use(express.json());
// ---------------------------------------------------------
// 1. THE EVENT STORE (Append-Only Database)
// ---------------------------------------------------------
// In a highly-scalable system, this would be Kafka, Cassandra, or EventStoreDB
const eventStore = [
// Example of how data is physically and immutably stored:
// { accountId: "123", type: "ACCOUNT_CREATED", amount: 0, timestamp: 1600000 },
// { accountId: "123", type: "DEPOSIT", amount: 100, timestamp: 1600050 }
];
// ---------------------------------------------------------
// 2. THE WRITE API (Appending Events)
// ---------------------------------------------------------
app.post("/api/bank/deposit", (req, res) => {
const { accountId, amount } = req.body;
// We NEVER query the current balance or UPDATE a row.
// We strictly append a historical event to the end of the ledger.
const newEvent = {
accountId: accountId,
type: "DEPOSIT",
amount: amount,
timestamp: Date.now(),
};
eventStore.push(newEvent); // Insert forcefully into DB
return res.status(201).json({ message: "Deposit event securely recorded." });
});
app.post("/api/bank/withdraw", (req, res) => {
const { accountId, amount } = req.body;
eventStore.push({
accountId: accountId,
type: "WITHDRAW",
amount: amount,
timestamp: Date.now(),
});
return res
.status(201)
.json({ message: "Withdrawal event securely recorded." });
});
// ---------------------------------------------------------
// 3. THE READ API (Rebuilding State dynamically on the fly)
// ---------------------------------------------------------
app.get("/api/bank/balance/:accountId", (req, res) => {
const targetId = req.params.accountId;
// 1. Fetch EVERY single event that has ever happened to this specific account
const accountEvents = eventStore.filter(
(event) => event.accountId === targetId
);
// 2. Mathematically rebuild the exact current state (The Balance)
let currentBalance = 0;
for (const event of accountEvents) {
if (event.type === "DEPOSIT") {
currentBalance += event.amount;
} else if (event.type === "WITHDRAW") {
currentBalance -= event.amount;
}
// Note: If type is ACCOUNT_CREATED, it mathematically does nothing to balance
}
// 3. Return the meticulously calculated state
return res.status(200).json({
accountId: targetId,
reconstructedBalance: currentBalance,
auditTrailLength: accountEvents.length,
});
});
app.listen(8080, () => console.log("Event Sourced Bank API running..."));4. Key Takeaways & Advanced Handling
- The Ultimate Audit Trail: Because the architecture literally only allows
INSERToperations (absolutely no updates, no deletes intrinsically permitted), it is logically impossible for a hacker or a rogue bug to silently erase historical data. You generate a legally binding, 100% accurate trail of exactly when, why, and how data changed. - Time Travel Debugging: Because you have an intact list of historically timed events, an engineer can literally "Time Travel". If a user reported a serious bug at 4:00 PM yesterday, the engineer can rebuild the system state by mathematically playing back all events up to exactly 3:59 PM yesterday and perfectly recreate the exact state of the system identical to when the bug historically happened.
- Snapshots (Fixing the Speed Issue): If an old bank account generates 50,000 transactions over 10 years, playing back 50,000 heavy events just to view their
$300balance is painfully slow. To fix this latency, systems use Snapshots. Every night at midnight, a worker job silently plays back the events, calculates the balance, and permanently saves a separate cached "Snapshot" (e.g.,Balance on Jan 1st = $500). The next morning, the API just loads the fixed Snapshot and simply applies the few new events from that morning on top of it.
