Most applications need to do two different kinds of work: create, update, and delete data safely, and retrieve it in a shape that screens and reports can use efficiently.

At Endowus, we handle these needs with CQRS and event sourcing across services built with Scala, Akka, and Kafka.

CQRS — Command Query Responsibility Segregation — is a design pattern that separates those two kinds of work instead of using one model for both. Commands such as placing an investment go through a write model that enforces business rules, while screens query a read model shaped for fast retrieval, filtering, and display.

Event sourcing stores the history of a business record as a sequence of events instead of overwriting it with only its latest state. When an entity accepts a command, it saves an event describing what happened and applies that event to update its state. If the entity restarts, it can rebuild the same state by replaying its events in order.

In our implementation, a separate event processor uses the stored events to update the read model asynchronously. This means the entity can complete a command before the read model has caught up, creating a short eventual-consistency gap.

For example, when a client clicks Invest, the write-side entity can process the instruction and return a confirmation before the event processor has updated the read model. If the client then opens Activities & Balances, the screen can still return the older state and leave the new investment temporarily invisible.

This post explains how we address that gap with a Read Routing Table (RRT). For each recent client-initiated change, RRT leaves a short-lived breadcrumb pointing to the entity that changed. A subsequent read uses those breadcrumbs to fetch and merge the latest state for only those entities into the normal read-model response.

How Changes Reach Our Read Models

A client action first reaches the write side, where an entity handles its command. Here, an entity is the component responsible for the state and business rules of one business record, such as an investment instruction.

When an entity accepts a command, it persists a domain event: a durable record of what happened, such as an instruction being created or a cancellation being requested. It then applies that event to its own state. If the entity restarts, it can rebuild the same state by replaying its event history.

An event processor consumes those events asynchronously and upserts (inserts or updates) one or more read models. The two sides have quite different jobs. An entity handles commands for one record at a time; a read model combines data from many records into the shape required by a screen. Activities & Balances, for example, needs to sort and filter transactions without loading every transaction entity.

The command can succeed once the entity has persisted its event. It does not wait for the event processor to finish updating every read model. A read arriving in between sees the timing gap shown below.

Timeline showing a read arriving before the read model updates
The entity has the latest state, but the event processor has not upserted the read model yet.

There are two straightforward ways to avoid this gap, but both come with an awkward trade-off. Reading every record from its entity would give us the latest data at the cost of the read model’s query performance and flexibility. Waiting for every read model before acknowledging a command would put asynchronous processing back on the critical path. RRT lets us keep the useful parts of both models.

What the Read Routing Table Stores

The table itself is deliberately simple. Before sending a client-initiated command to an entity, the write path adds a short-lived row to RRT. We call that row a breadcrumb because it says, in effect, that a particular client touched a particular entity recently. It stores enough information to locate the entity, along with a small context hint about what changed; the business data stays with the entity.

We reserve breadcrumbs for client-triggered changes. The deciding factor is whether someone is likely to look for confirmation of an action they just completed. After a client clicks Invest, the next screen should show the instruction that was just confirmed. A dividend reinvestment started in the background has no equivalent action-and-response moment, so it follows the normal event-processing path without leaving a breadcrumb.

Read Routing Table write and read flow
The read model supplies the base response; recent breadcrumbs identify the entities whose state may be fresher.

Reading Through RRT

A screen load still begins with its read model. RRT also looks for recent breadcrumbs relevant to that client and query. With no breadcrumbs, the read-model response goes back unchanged.

When breadcrumbs exist, their entity IDs tell us exactly which entities to query. That small set puts a bound on the extra work: RRT does not wake every entity that could contribute to the screen, only those that changed recently. We patch their latest state into the read-model response, while unrelated records stay on the normal read path.

In simplified Scala, the flow looks like this. The names are illustrative rather than taken from our production code:

def loadClientView(clientId: ClientId): Future[List[Record]] =
  for {
    base <- readModel.load(clientId)
    changedIds <- recentChanges.find(clientId)
    latest <- loadRecentSourceRecords(changedIds)
  } yield {
    mergeByIdAndStatus(base, latest)
      .filter(recordShouldBeShown)
  }

The merge needs more information than a record ID, and the filter has to run last. We ran into both details while applying RRT to Activities & Balances.

A Transaction ID Is Not Enough

The Activities & Balances screen normally gets its transactions from the transaction service’s read model. During the consistency gap, the query can supplement that base through two different paths.

If a projected transaction exists but is stale, an RRT breadcrumb can route the query to the entity that accepted the recent command. The entity’s current state is then used to patch the older row from the read model.

A newly created plan presents a different case. Its first investment may need to appear before the transaction service has projected any row for it, so there is nothing for an entity result to patch. The query instead uses data from an upstream source to construct the missing transaction until the read model catches up. This is a separate upstream fallback, not another read from the RRT-targeted entity. These are different data paths even if, in a particular flow, the entity and upstream data happen to come from the same service.

The entity and upstream representations are usually fresher but less complete than a projected transaction. The merge therefore has two jobs: add a transaction when the read model has no row, and replace a row when its client-visible status is stale.

Matching by transaction ID is the natural first attempt. It confirms that two records describe the same transaction, but it cannot tell us which version is newer or more complete. Consider a cancellation: the read model might still say that the instruction was created while the entity says that its cancellation is already in progress. Choosing the read-model record merely because the ID exists would move the status backwards on screen.

The transaction read model, RRT-routed entity state, and an upstream fallback feeding a status-aware merge
The merge distinguishes an absent projection from a stale one, then keeps the richer read-model record once the projection and fresher source agree.

We handle the possible matches as follows:

  • If the upstream fallback returns a transaction that is absent from the read model, add it to the response.
  • If the read model and entity have the same transaction ID but different statuses, use the entity state.
  • If the ID and status match, keep the read-model record because it usually has more data for the screen.

We use entity and upstream data only for the early stages they can represent accurately: creation, initial submission, and cancellation. At that point they have enough information to reflect the client’s latest action. Later stages depend on broker, order, settlement, and other details that live in the richer transaction read model. Allowing a partial record from an earlier source to replace that later representation could make the transaction appear to move backwards.

Merge Before Filtering

Filter ordering has an easy-to-miss trap. Suppose an active-only view gets a transaction that is active in the read model but already inactive in its entity. If we filter the entity result first, the inactive record disappears before the merge sees it.

At that point, an empty entity result is ambiguous. It could mean that there was no newer record, or that there was one but the active-only filter removed it. The merge cannot tell those cases apart, so it has nothing with which to replace the old active version from the read model. The stale transaction remains on screen.

Latest entity state replacing an old read-model record before filtering
Replace the old record first; apply the screen filter to the merged result.

The read path therefore runs in this order:

  1. Load the read-model response.
  2. Load complete records for the recent changes.
  3. Merge them by ID and status.
  4. Apply the screen’s filters.

Now the inactive record replaces the active version before the filter removes it. The same ordering handles records that move between categories or become visible for the first time.

Operational Boundaries

These limits are what make RRT practical for us.

Four rules that keep the Read Routing Table small
Breadcrumbs are short-lived and scoped; entity reads and state mappings remain narrow.
  • Short lifetime: breadcrumbs expire after the period in which the read model is expected to lag.
  • Request scope: lookups are limited to the client and entity type relevant to the query.
  • Targeted reads: only entities named by recent breadcrumbs are queried.
  • Lifecycle boundary: each integration maps only the early states it can represent accurately.

The table holds breadcrumbs rather than copies of business records. Entities remain responsible for current state, and read models remain responsible for normal screen queries.

How This Helps Us Deliver a Better User Experience

Clients do not experience a write model and a read model. They experience one app. If the app confirms an investment and then appears to forget it on the next screen, the fact that the command succeeded and only the projection is lagging makes little difference—the app feels unreliable.

That matters when someone has just acted on their money. A confirmation should reduce uncertainty, not replace it with a new set of questions: Was the instruction received? Is the account up to date? Should I submit it again? When the client has to reason about those possibilities, the complexity of our system has leaked into their experience.

There are good reasons for keeping our write and read paths independent. Asynchronous projections let each side do its job well: commands can complete without waiting for every downstream view, and screens can query data shaped for them. But eventual consistency does not have to mean accepting a visibly inconsistent journey. RRT lets us preserve the architectural benefits of CQRS and event sourcing while closing the gap at the moment the client is most likely to notice it.

The gap may last only a few seconds, but those are precisely the seconds in which the client is looking for reassurance. When RRT does its job, its machinery disappears from the experience: the client acts, the app reflects it, and they can move on with confidence. A small consistency detail becomes part of making the whole app feel considered and dependable.