Logo
Back to blog
Jul 31, 2026 · 4 min read

Architecture

Ledger: consistency and traceability without overwriting state

How chronological, immutable, and auditable records help maintain consistency in distributed systems.

By Vinícius Amélio

Cover for Ledger: consistency and traceability without overwriting state

The problem

If you have worked with systems where integrity and consistency are critical, or have gone through a technical interview at a digital bank, for example, you have probably faced a situation where duplicate database writes were a concern.

In small applications, where we have a single database and a single API instance, an obvious solution such as a database transaction or a mutex on an in-memory resource may be enough. But what happens as the system scales and we have multiple instances, distributed databases with sharding, and a flood of concurrent requests?

Solutions that involve some kind of locking quickly become impractical as response times and latency grow exponentially. What now?

A solution from outside software development

Notice that I explicitly used a digital bank as the example in the previous section. In reality, any system that critically depends on consistency suffers from the same problem. Inventory management, points systems, logistics, and healthcare systems are a few examples. All of them rely heavily on a fully traceable, auditable history.

In finance, we have the concept of a ledger, a record of transactions that can be audited. A logistics system keeps a history of product movements. A healthcare system needs to keep track of changes to medical records.

If these examples remind you of logs, then you are on the right track, in a sense.

Ledger

A ledger is a way of recording events or transactions that is usually:

  • Chronological
  • Immutable or append-only
  • Auditable
  • Capable of reconstructing the current state from previous transactions

This concept can be especially useful when you may need to answer questions such as:

  • How did we arrive at this state?
  • Who made this change?
  • Do we need to provide evidence of these transactions during an audit?

The main shift in thinking when using this concept lies in how we handle state. In a conventional application, you would overwrite the existing state with something like an UPDATE in a SQL database.

With a ledger, you record the changes that led to the current state.

Potential performance issues

It is natural to wonder about the potential performance issues of an approach where the current state is reconstructed from every previous transaction. In practice, this reconstruction does not need to happen every time you check your balance, for example.

Some approaches we can use to avoid reading an enormous number of records while reconstructing state are:

Projections

Projections are very common in systems that use Event Sourcing and/or CQRS. The flow can be represented as follows:

Command

Ledger

Event published

Consumers update projections

API queries projections

In this context, we keep different projections for each type of query, such as account balances, customer transactions, and fraud analysis. None of them needs to reconstruct state at query time because events update them whenever changes occur in the ledger.

Snapshots

If, for some reason, you do need to reconstruct state at query time, you can use snapshots to periodically store intermediate ledger states and speed up that reconstruction.

Imagine, for example, an aggregate with 100,000 events. Instead of processing all of them, you store:

Snapshot at event 95,000:
balance = 12,500
status = ACTIVE

During the next reconstruction, you read the snapshot at event 95,000 and process only events 95,001 through 100,000.

A snapshot can contain data such as aggregate_id, state, created_at, and version. Conceptually, the reconstruction would look like this:

const snapshot = await snapshotRepository.findLatest(accountId);
 
const state = snapshot
  ? deserialize(snapshot.state)
  : Account.initialState();
 
const events = await ledger.findAfter(
  accountId,
  snapshot?.lastEventPosition ?? 0,
);
 
return events.reduce(applyEvent, state);

Snapshots are particularly useful when an aggregate has many events or when the reconstruction logic is complex.

It is worth noting that you do not need to generate snapshots after a fixed number of events. You can also use a time interval or the aggregate version as the criterion.