Marcio Cunha

Complex Domain Modeling with Event Sourcing and Asynchronous Read Projections

Learn how to structure complex enterprise systems using Event Sourcing to preserve exact change history and asynchronous projections for fast querying.

Marcio Cunha•6 min
Also available in:PortuguêsEspañol
Summary
  • Event-based storage preserves the immutable history of all operations performed within the system.
  • Strict separation between write and read models prevents resource contention under high workloads.
  • Asynchronous projections update query-optimized tables without blocking the main write pipeline.
  • Eventual consistency requires deliberate design choices to handle minor interface delays smoothly.
  • Rebuilding current state from scratch simplifies auditing and failure investigation processes.

The Challenge of Mapping Business Rules in Modern Systems

When building software to solve real-world problems, we frequently face the pain of compressing complex workflows into traditional relational tables. In a conventional application, we only save the current snapshot of a record's state, overwriting older data. In practice, this means we lose the temporal narrative, the context that led to a specific decision, and the ability to audit the past with precision. For domains where every transaction matters—such as finance, logistics, and healthcare—this loss of history creates a gulf between the code and business reality.

Rich domain modeling requires software to speak the exact language of domain experts. When a data model is built merely to save disk space, it fails to reflect vital behaviors. Instead of asking only what a bank account's current balance is, the system should understand the precise sequence of deposits and withdrawals that generated that balance. It is precisely in this high-complexity scenario that event-driven approaches gain traction, transforming how we think about persistence and data consistency at scale.

Understanding Event Sourcing in Practice

Event Sourcing is an architectural strategy where we do not save the final state of an object, but rather the chronological list of all facts that happened to it. Think of a bank statement: you don't just have the number '$1,500' stored in a column; you have a list of events like 'AccountCreated', 'DepositMade of $2,000', and 'WithdrawalMade of $500'. The current state is nothing more than a mathematical consequence derived from sequentially replaying those events from the beginning of time.

In practice, this means the database becomes an append-only log, where records are never updated or deleted. This characteristic brings an immense concurrency advantage, as sequential log writes generate very little contention compared to updating individual rows in conventional tables. Furthermore, if we discover a bug in an old business rule, we can fix the code and reprocess the events to retroactively recalculate state, ensuring unprecedented traceability for the software.

To illustrate how we handle these events in code, here is a C# example showing the basic structure of an aggregate applying a business event:

public class BankAccountAggregate : AggregateRoot
{
    private decimal _balance;
    private bool _active;

    public void Apply(AccountCreatedEvent eventData)
    {
        _active = true;
        _balance = eventData.InitialBalance;
    }

    public void Apply(MoneyDepositedEvent eventData)
    {
        if (!_active) throw new InvalidOperationException("Account is inactive.");
        _balance += eventData.Amount;
    }
}

The Crucial Role of Asynchronous Read Projections

Storing only events brings an obvious operational challenge: how do we quickly display a customer list or consolidated balance on the user interface? If we had to read an account's entire event history every time someone opened the app, the system would become unbearably slow. This is where asynchronous read projections come in, acting as dedicated listeners designed to translate the continuous stream of events into query-optimized tables.

In practice, these projectors listen to the event bus in the background, calculate the consolidated state, and write the result to a secondary database, such as a standard relational base or a NoSQL document store. This concept aligns with the CQRS pattern, which separates write responsibility from read responsibility. While writing focuses on rigorous business rule validation and secure fact storage, reading focuses purely on performance and user interface usability.

Because this process occurs outside the user's main request lifecycle, it is asynchronous. This introduces the concept of eventual consistency, meaning there might be a fraction-of-a-second delay between an event being recorded and it appearing on query screens. For the vast majority of enterprise applications, this trade-off is heavily outweighed by the achieved scalability and structural robustness.

Managing Concurrency and Distributed Reliability

When we remove traditional database locking and adopt event-driven architectures, we need to handle concurrency differently. If two users try to modify the same aggregate at the exact same time, how do we prevent one from silently overwriting the other's work? The answer lies in using optimistic concurrency control via version numbers on each event stream.

In practice, each new batch of events generated by a command carries the expected version number of the aggregate. When the persistence mechanism attempts to save these events, it verifies if the version in the database matches the submitted version. If another process wrote something in the meantime, the version changes, the transaction is rejected due to conflict, and the client application is instructed to retry with updated data. This model eliminates pessimistic locking bottlenecks, allowing thousands of operations to occur simultaneously without blocking connections.

Below is a Node.js example demonstrating how an event handler updates a read projection in a lightweight relational database:

async function handleMoneyDeposited(eventData, dbPool) {
    const query = `UPDATE customer_balances SET balance = balance + $1, updated_at = NOW() WHERE customer_id = $2`;
    await dbPool.query(query, [eventData.amount, eventData.customerId]);
}

This type of routine ensures that the read model stays synchronized with the messaging bus, operating in a decoupled and resilient manner against momentary downtime of dependent microservices.

Strategies for Migration and Schema Evolution

Maintaining an event-based system for several years introduces a unique challenge: what happens when an event structure needs to change? In traditional relational databases, we alter a column with a migration command and the problem is solved. In Event Sourcing, events stored in the past are immutable; you cannot simply rewrite the history of transactions that occurred in production three years ago.

To solve this dilemma, we use versioning and upcasting strategies. Upcasting consists of intercepting old events as they are read from storage and dynamically transforming them into the latest version structure before passing them to the domain. This way, the application core deals only with the updated version of objects, while the historical archive remains intact and faithful to what actually happened at the time of original emission.

Another critical point is cleanup and snapshotting. Since an aggregate's history can grow indefinitely, reprocessing thousands of events on every object load can degrade performance. The solution is to create periodic snapshots of the consolidated state, allowing the application to read the latest snapshot and process only the few events generated after it, uniting the best of both worlds: full auditability and high execution speed. To complement this implementation in production environments, see our related technical analyses on Domain Modeling with Event Sourcing and Read Projections and Domain Modeling with Event Sourcing and Asynchronous CQRS Projections in NoSQL.

Final Thoughts on Event-Driven Architectures

Adopting complex domain modeling with Event Sourcing and asynchronous read projections requires technical maturity and clarity about the problems you are trying to solve. It is not a silver bullet applicable to any simple CRUD, but rather a powerful tool for rich domains where traceability, rigorous auditing, and read-write scalability justify the initial implementation effort.

By separating the transactional complexity of fact storage from the agility of read projections, we build highly resilient systems capable of evolving alongside business demands without corrupting the past. Investing in mastering these architectural patterns pays lasting dividends in the form of more transparent software, easier to debug, and prepared to grow frictionlessly.