> ## Content Index
> Fetch the complete content index at: https://ricofritzsche.me/llms.txt
> Use this file to discover other available public pages before exploring further.

# How to Enforce Consistency Without Aggregates in Event-Sourced Systems
- URL: https://ricofritzsche.me/how-to-enforce-consistency-without-aggregates-in-event-sourced-systems/
- Published: 2025-07-28T19:37:11.000Z
- Updated: 2025-07-29T08:11:35.000Z
- Description: A Practical Approach Using Context-Scoped Filters and SQL
- Author: Rico Fritzsche
- Tags: Event Sourcing, Command Context Consistency, CQRS/ES, PostgreSQL, TypeScript, Software Engineering

Consistency is essential. However, the way in which it is enforced is more important than most developers realize.

In traditional event-sourced systems, consistency is tied to the aggregate. You replay past events, to restore the aggregate state, make a decision and then try to append new events, but only if the version of the aggregate hasn’t changed. This approach is safe, but also coarse. It assumes that any change to the aggregate renders every command unsafe.

Today, however, I think differently. I’ve shifted the consistency boundary to the command context. I no longer assume that a central state object needs to enforce consistency. Instead, I now enforce consistency per command, relative to the event context that the command actually reads.

This might sound like a small difference, but it fundamentally changes how you design and evolve behavior. You stop coupling unrelated commands just because they operate on the same entity. You gain flexibility, clarity and fewer unnecessary conflicts.

In this article, I’ll explain the technicalities step by step. This will include the exact interface, logic and SQL query that we use in our PostgreSQL event store implementation.

## How Aggregate Versioning Works (And Why It’s Too Broad)

In most event-sourced systems, consistency is enforced using aggregate versioning. The idea is simple: every time you apply a command, you load the aggregate’s event history, make a decision, and try to append new events but only if no other events have been added in the meantime.

This is usually implemented by tracking a version number (or the highest sequence number) of the last event. When appending, the system checks if that version still matches. If not, the command fails and must be retried.

This works. But it’s a **coarse-grained guarantee**.

Let’s say two commands both target the same *InventoryItem* aggregate:

- *CheckOutInventory* relies on past check-ins and audits.
- *RenameItem* changes the display name of the item.

They don’t care about each other. But because they touch the same aggregate, they block each other. A rename operation that has nothing to do with stock levels can make a valid check-out fail.

That’s not enforcing meaningful consistency. That’s just treating all change as equal regardless of whether it affects the decision being made.

This kind of model introduces:

- **False conflicts**, where unrelated commands fail due to irrelevant changes
- **Unnecessary retries**, even if the business rule was still satisfied
- **Coupling by identity**, where logic becomes entangled just because it's routed through the same aggregate

It’s safe, but it’s also a bottleneck. But what's the solution? Shift the boundary.

## Command Context Consistency 

Instead of versioning the whole aggregate, we enforce consistency relative to the **event context** a command actually uses to make its decision.

Each command defines a filter: a combination of event types and optionally payload criteria that describe the subset of events it cares about. This filter forms the *command context*. When the command is run, it queries the store for all matching events and reads the highest sequence number (*maxSequenceNumber*) found.

This *maxSequenceNumber* is a version of the context, rather than of the entire entity.

When the command emits new events, it includes the same filter and the *maxSequenceNumber* observed. The append will only succeed if the number hasn't changed, meaning that no relevant events have been added in the meantime.

What’s different here?

- If something changed **outside** the command’s context, the command proceeds.
- If something changed **within** the context, the append fails, and the command must be retried using a fresh query and decision.

This aligns consistency with causality:

- You don’t block unrelated operations.
- You only reject commands that might now produce a different result based on new facts.

This is especially powerful in systems with broad event types and many loosely coupled rules. Instead of centralizing everything into one object, you let each decision stand on its own, based on the facts it actually needs.

The result is leaner logic, fewer conflicts, and a clearer connection between what a command reads and what it protects.

## The Core Algorithm: Query + Append with Context Check

At the heart of this model is a simple two-step process:

1. **Query the event store** using a command-specific filter
2. **Append new events** only if the highest sequence number of the context hasn’t changed

This is the core idea behind command context consistency: the command operates within a filtered view of the event log, and appends are allowed only if that view remains unchanged between reading and writing.

### The Interface

Our *EventStore* interface reflects this pattern directly:

```TypeScript
export interface EventStore {
  query(filter: EventFilter): Promise<QueryResult>;
  append(
    events: Event[], 
    filter?: EventFilter, 
    expectedMaxSequenceNumber?: number
  ): Promise<void>;
}

```

- *query()* loads all events matching a given *EventFilter* and returns both the events and the highest sequence number of the query context (*maxSequenceNumber*).
- *append()* writes new events, but only if the *maxSequenceNumber* for the same filter still matches the expected value.

This gives you **causal protection** per command.

### The Contract

Let’s make this explicit:

- A command queries the event store using a filter ***F***
- It receives a QueryResult, which includes a set of relevant past events and their maxSequenceNumber, which is ***N***.
- The decision function runs based on those events and returns a list of new events.
- When appending, we re-apply the **same filter *F,*** check whether the **current *maxSequenceNumber* is still *N ,*** append only if the condition holds.

![](https://storage.ghost.io/c/5b/3f/5b3fa9cb-95b3-4667-98c5-ca3c2c9a3233/content/images/2025/07/Screenshot-2025-07-28-at-18.26.10.png)

Optimistic Concurrency Control Flow

If the *maxSequenceNumber* has changed, the context is no longer valid. Something relevant has happened since the decision was made. The append is aborted and the command must be retried, starting with a new query and decision.

This guarantees that every decision is made against the most recent context without assuming full control over an entire aggregate.

## A Concrete Example: Check-Out Inventory Without False Conflicts

Imagine we’re developing a basic warehouse system. Each item can be checked in or out, renamed, or audited. All of these actions generate events that are stored in a single events table.

Now consider the following command: *CheckOutInventory*.

This command:

- Reduces stock for an item
- Fails if not enough stock is available

To make this decision, it needs a very specific context:

- Past *InventoryCheckedIn* events
- Past *InventoryCheckedOut* events
- Possibly *InventoryAudited* events (if used to correct quantity)

### The Query

We use a filter like:

```json
{
  eventTypes: ['InventoryCheckedIn', 'InventoryCheckedOut', 'InventoryAudited'],
  payloadPredicates: [{ itemId: 'abc-123' }]
}

```

The event store returns all matching events and the highest sequence number in that set, which is *1842*.

The decision is made based on this context. If it passes, we emit:

```json
[
  {
    eventType: 'InventoryCheckedOut',
    payload: { itemId: 'abc-123', quantity: 2 }
  }
]

```

We then append the new event using the same filter and the expected *maxSequenceNumber* (*1842*). Now, here’s the key part:

#### Valid Case: Non-conflicting update

The item was renamed (*ItemRenamed* event) at sequence number *1843*.  
This does not affect the outcome of the *CheckOutInventory* process. The context we care about remains unchanged. Append proceeds successfully.

#### Invalid Case: Conflicting update

In the meantime, someone checked out five units (*InventoryCheckedOut* at sequence number *1843*). The relevant context has changed. *Append* is aborted. The command must re-query and re-evaluate to produce a different result, if necessary.

This is more than just an optimization. It’s a more precise model of causality.  
Only relevant changes result in retries. Commands protect the facts they are concerned with. There are no global locks or false conflicts.

## PostgreSQL Implementation

To enforce this context-local consistency at the database level, we use a **single atomic SQL statement** that both:

1. Re-evaluates the current max(sequence\_number) of the context
2. Appends new events only if the context has not changed

### The *append* Operation

Here’s how the core append logic works:

```TypeScript
await eventStore.append(events, filter, expectedMaxSequenceNumber);

```

- *events*: The new events that persist after the decision function has run.
- *filter*: The same event filter that defined the context of the command.
- *expectedMaxSequenceNumber*: The value observed during the query phase.

This combination ensures **only** relevant changes trigger a retry.

### How It’s Enforced in SQL

We generate a query like this:

```ts
export function buildCteInsertQuery(filter: EventFilter, expectedMaxSeq: number): { sql: string, params: unknown[] } {
  const contextVersionQueryConditions = buildContextVersionQuery(filter);
  
  const contextParamCount = contextVersionQueryConditions.params.length;
  const eventTypesParam = contextParamCount + 1;
  const payloadsParam = contextParamCount + 2;

  return {
    sql: `
    WITH context AS (
      SELECT MAX(sequence_number) AS max_seq
      FROM events
      WHERE ${contextVersionQueryConditions.sql}
    )
    INSERT INTO events (event_type, payload)
    SELECT unnest($${eventTypesParam}::text[]), unnest($${payloadsParam}::jsonb[])
    FROM context
    WHERE COALESCE(max_seq, 0) = ${expectedMaxSeq}
    RETURNING *;
    `,
    params: contextVersionQueryConditions.params
  };
}

```

The context *CTE* (Common Table Expression) uses the same event filter as during the decision phase, matching event types and optional payload predicates. It returns the maximum sequence number found in that context.

The *INSERT* only proceeds if this value matches the *expectedMaxSequenceNumber* that was passed in. If not, no events are appended. The operation fails silently and the caller is aware that the context has changed. 

From there, the command can simply be retried: re-queried and re-evaluated, with another attempt to append being made.

This approach has several advantages:

- Atomicity: the check and insert happen in a single query.
- Efficiency: no locks or serializable isolation are required.
- Precision: only relevant changes cause a conflict.
- Concurrency safety: multiple commands can operate in parallel with different filters without interfering with each other.

## Implementation and Reference Code

The approach described here is fully implemented in the PostgreSQL-backed event store of the [EventStore NPM package](https://www.npmjs.com/package/@ricofritzsche/eventstore?ref=ricofritzsche.me). 

[@ricofritzsche/eventstoreA TypeScript event sourcing library with Postgres persistence, real-time subscriptions, and projection support for building responsive event-sourced applications. Latest version: 1.0.5, last published: 3 days ago. Start using @ricofritzsche/eventstore in your project by running \`npm i @ricofritzsche/eventstore\`. There are no other projects in the npm registry using @ricofritzsche/eventstore.![](https://storage.ghost.io/c/5b/3f/5b3fa9cb-95b3-4667-98c5-ca3c2c9a3233/content/images/icon/3dc95981de4241b35cd55fe126ab6b2c.png)npm![](https://storage.ghost.io/c/5b/3f/5b3fa9cb-95b3-4667-98c5-ca3c2c9a3233/content/images/thumbnail/338e4905a2684ca96e08c7780fc68412.png)](https://www.npmjs.com/package/@ricofritzsche/eventstore?ref=ricofritzsche.me)

It’s part of an ongoing collaboration with [Ralf Westphal](https://ralfwestphal.substack.com/), focused on building practical tools for [Aggregateless Event Sourcing ](https://ricofritzsche.me/aggregateless-event-sourcing/)(AES) with [Command Context Consistency](https://ralfwestphal.substack.com/p/command-context-consistency) (CCC).

The code is open source and includes:

- A minimal *EventStore* interface with clear semantics
- A PostgreSQL adapter that enforces context-local consistency via SQL
- Utilities to build filters, queries, and safe conditional inserts
- Examples of command-side usage and basic projections

You can explore the repository and usage examples here: 

[GitHub - ricofritzsche/eventstore-typescript: A TypeScript implementation of a functional event sourcing system that provides persistent event storage with optimistic locking and payload-based querying.A TypeScript implementation of a functional event sourcing system that provides persistent event storage with optimistic locking and payload-based querying. - GitHub - ricofritzsche/eventstore-typ…![](https://storage.ghost.io/c/5b/3f/5b3fa9cb-95b3-4667-98c5-ca3c2c9a3233/content/images/icon/pinned-octocat-093da3e6fa40.svg)GitHubricofritzsche![](https://opengraph.githubassets.com/b7a5db090be30081e07a3c27ad776ef7189dea22e213271a36f3fb52c8bdb014/ricofritzsche/eventstore-typescript)](https://github.com/ricofritzsche/eventstore-typescript?ref=ricofritzsche.me)

It's not a framework. There are no hidden conventions. Just a thin, explicit layer over PostgreSQL that helps you model decisions with precision without aggregates, and without unnecessary complexity.

*Cheers*!