The Read Model Zoo: Projections Beyond Tables

goloroden1 pts0 comments

The Read Model Zoo: Projections Beyond Tables - EventSourcingDB

Skip to content

Initializing search

Getting Started

Fundamentals

Deployment and Operations

Reference

Client SDKs

Extensions

MCP Server

Best Practices

Implementation and Development

Data Management and Performance

Operations, Compliance and Infrastructure

Common Issues

Blog

Categories

Privacy Policy

Legal Notice

The Read Model Zoo: Projections Beyond Tables¶

Say "projection" to most developers and they reach, almost reflexively, for a SQL table. Denormalized, perhaps materialized, but ultimately rows in a relational database. It happens so quickly that it doesn't feel like a decision. It feels like the definition of the word.

It isn't. A read model is just a query-optimized view of the event history, and the shape it takes should follow the query, not the convention. There are at least four other shapes worth knowing about, each one fitting a class of queries that a SQL table either can't handle well or has no business handling at all. Once you've seen the menu, the reflex gets harder to justify.

The Reflex: A SQL Table¶

Let's start by being fair to the default. There's a reason it's the default.

Imagine a library system. Events like BookBorrowed, BookReturned, and BookReserved flow into the event store. A common query: "which books does reader 42 currently have borrowed?" This is a transactional lookup. It needs a current snapshot, indexed by reader ID, with a predictable shape. A SQL table is exactly right for this.

The projection that builds it is straightforward. On BookBorrowed, INSERT a row. On BookReturned, DELETE it. PostgreSQL or MySQL handles the storage, indexing handles the lookup, and the query response is in single-digit milliseconds. There's nothing wrong with this picture, and nothing exotic about it. For lookups of current state by a known key, the SQL table earns its place.

The trouble starts when the same shape gets pressed into service for queries it wasn't designed for. That's when projections start to creak.

The Same Events, Different Questions¶

Step back for a moment. Where did the SQL table come from? Not from the events themselves. BookBorrowed doesn't care whether it lives in a row, a graph node, or a JSON file. The table came from the query you anticipated . You expected someone to ask "which books does this reader have?", you reasoned backward to a shape that answers it efficiently, and you wrote a projection that maintains that shape.

That's the entire game. The events are the source of truth; everything else is shaped to fit a question . If you're writing CQRS-style systems, this framing should be familiar from our post on CQRS Without the Complexity . The read side serves queries. The write side records facts. They have different jobs and, importantly, different optimal data structures.

The interesting consequence is that different queries want different shapes . A full-text search wants an inverted index. A "who knows whom" question wants a graph. A "find things like this" question wants a vector space. A monthly summary wants almost nothing at all. None of these are well-served by a row in a table, and forcing them into one is how teams end up with stored procedures that nobody can debug and LIKE '%foo%' queries that lock the database.

Let's walk through four other shapes the same library events can take.

A Search Index for Full-Text Queries¶

Suppose readers want to search book descriptions and reviews. "Find me books about consciousness." A SQL LIKE '%consciousness%' works as a tech demo and falls apart under any real load. Worse, it can't do stemming, synonyms, multi-language tokenization, or relevance ranking.

A search engine like Elasticsearch, OpenSearch, Meilisearch, or Tantivy is built for exactly this. The projection feeds it: on BookDescribed, upsert a document; on BookReviewed, append the review text into the document; on BookDeleted, remove the document. Hooked into the SDK's observeEvents iterator, the projection looks like this:

for await (const event of client.observeEvents('/books', { recursive: true })) {<br>if (event.type !== 'io.eventsourcingdb.library.book-described') {<br>continue;

await searchIndex.upsert({<br>id: event.data.bookId,<br>title: event.data.title,<br>description: event.data.description,<br>updatedAt: event.time.toISOString(),<br>});

What you gain is a query path that actually fits the question : phrase matching, relevance scoring, faceting by genre, suggestions for misspellings. What you give up is transactional consistency with the rest of your system, because the index updates asynchronously, like every projection. That's not a regression. That's just the read side being honest about how it works , as we discussed in Read-Model Consistency and Lag .

A Graph for Relationships&para;

Now imagine a different query: "what other books did people who borrowed Crime and Punishment also borrow?" Or "find a chain of co-borrowers between Reader 42...

like table event read query projection

Related Articles