Skip to content

Read Models

Projections build read models. But where do those documents live, and how do you query them?

Nagare includes a document store that persists read models as JSON documents in your existing database. No separate read database needed, though you can add one later.

Setting up a document store

A document store needs a type that implements IDocument:

csharp
public class BookReadModel : IDocument
{
    public string Id { get; set; }
    public string Title { get; set; }
    public string Author { get; set; }
    public bool IsBorrowed { get; set; }
    public string? BorrowerId { get; set; }
}

The Id property maps to the aggregate ID by default.

Registration

Register a document store alongside your projection:

csharp
// SQL Server
builder.Services.AddRepositoryStore<
    IBookRepository, BookRepository>();

// PostgreSQL
builder.Services.AddRepositoryStore<
    IBookRepository, BookRepository>();

The framework creates the backing table on startup and handles serialization to and from JSON.

Querying read models

The document store gives you a repository to query against. Define an interface and implementation:

csharp
public interface IBookRepository
{
    Task<BookReadModel?> GetById(string id);
    Task<IReadOnlyList<BookReadModel>> GetBorrowed();
}

public class BookRepository : IBookRepository
{
    private readonly SqlServerDocumentStore<BookReadModel> _store;

    public BookRepository(SqlServerDocumentStore<BookReadModel> store)
    {
        _store = store;
    }

    public async Task<BookReadModel?> GetById(string id)
        => await _store.Get(id);

    public async Task<IReadOnlyList<BookReadModel>> GetBorrowed()
        => await _store.Query("IsBorrowed", true);
}

JSON indexes

By default, the entire read model is stored as a single JSON column. To query specific fields without deserializing every row, define indexes:

csharp
public class BookProjection
    : SqlServerDocumentStoreProjection<BookEvent, BookReadModel>
{
    public override string TableName => "book_read_models";

    public override IReadOnlyList<JsonIndex> Indexes =>
    [
        new JsonIndex("ix_borrowed", new JsonProperty("IsBorrowed", JsonType.Bool)),
        new JsonIndex("ix_author", new JsonProperty("Author", JsonType.String))
    ];

    // ... Apply method
}

The framework creates SQL indexes on the JSON paths you specify. This gives you fast filtered queries without maintaining a separate relational schema.

Supported property types

JsonTypeSQL type
StringNVARCHAR(450) / TEXT
IntINT / INTEGER
BoolBIT / BOOLEAN

Exposing read models via API

A typical pattern is to wire the repository directly into your endpoints:

csharp
app.MapGet("/books/{id}", async (string id, IBookRepository repo) =>
{
    var book = await repo.GetById(id);
    return book is not null ? Results.Ok(book) : Results.NotFound();
});

app.MapGet("/books/borrowed", async (IBookRepository repo) =>
{
    var books = await repo.GetBorrowed();
    return Results.Ok(books);
});

The write side and read side are separate

Commands go through the aggregate. Queries go through the read model. They share the same database but operate on different tables.

The projection builds the read model asynchronously. There is a brief delay (typically milliseconds, controlled by PollDelay in subscription options) between when an event is appended and when the read model reflects it.

If you need immediate consistency after a write, read from the aggregate directly:

csharp
// Immediate: load the aggregate and inspect its state
var aggregate = await repo.Load(new AggregateId(bookId));
var state = aggregate.State;

// Eventually consistent: query the read model
var readModel = await bookRepo.GetById(bookId);

Multiple read models from one stream

The same event stream can feed many projections, each building a different read model:

csharp
// Dashboard view: shows all books with borrower info
builder.Services.AddSubscription<BookDashboardProjection, BookEvent>();

// Search view: optimized for full-text search
builder.Services.AddSubscription<BookSearchProjection, BookEvent>();

// Statistics view: counts by author, borrow frequency
builder.Services.AddSubscription<BookStatsProjection, BookEvent>();

Each projection has its own table, its own checkpoint, and its own pace. If the statistics projection falls behind, the dashboard projection keeps running.

When to use a separate read database

The built-in document store covers most cases. Consider a separate read database when:

  • You need full-text search (Elasticsearch, Algolia)
  • Your read query patterns don't fit JSON documents (complex joins, aggregations)
  • Read and write workloads need independent scaling

For these cases, implement ISubscription<TEvent> directly and write to whatever storage you need:

csharp
public class BookSearchSubscription : ISubscription<BookEvent>
{
    public SubscriptionId SubscriptionId => new("book-search");

    public async Task Handle(EventEnvelope<BookEvent> evt)
    {
        if (evt.Event is BookAdded added)
        {
            await _searchClient.Index(new {
                Id = evt.AggregateId.Value,
                Title = added.Title,
                Author = added.Author
            });
        }
    }
}

Design guidelines

  1. Shape read models for specific use cases. A dashboard read model and a search read model should be different types, even if they draw from the same events.

  2. Keep read models denormalized. Joins at query time defeat the purpose. Include everything the consumer needs in the document.

  3. Read models are disposable. Delete the table, reset the checkpoint, and the projection rebuilds everything from the event stream. Don't be afraid to change the schema.

  4. Index only what you query. Every JSON index adds write overhead. Start without indexes and add them when you have actual query patterns.


Next: Testing

流れ — flow.