Skip to content

Eventual Consistency in Projections

Projections in Chronicle operate under the principle of eventual consistency, meaning that read models are updated asynchronously as events are processed. This design provides significant performance benefits but requires understanding of how and when data becomes consistent.

When you append an event to Chronicle, the following sequence occurs:

  1. Event is persisted to the event store immediately
  2. Event append operation returns successfully to the caller
  3. Projections are updated asynchronously in the background
  4. Read models become consistent after processing completes

This means there’s a brief window where:

  • The event has been successfully stored
  • But projections may not yet reflect the changes

Chronicle processes events for projections asynchronously to ensure optimal performance and scalability:

Event Appended

Event Stored

Append Operation Returns

Background Processor

Projection Updated

Reducer Executed

Observer Notified

  • High Throughput: Event appends don’t wait for projection updates
  • Scalability: Projection processing can be scaled independently
  • Resilience: Failed projection updates don’t affect event persistence
  • Performance: Read and write operations are optimized separately
  • Event Ordering: Events for the same event source are processed in order
  • At-Least-Once Processing: Every event will be processed (with retries on failure)
  • Partition Consistency: All projections for a single event source will be eventually consistent
  • Immediate Consistency: Projections may lag behind events
  • Cross-Partition Ordering: Events from different event sources may be processed out of relative order
  • Synchronous Updates: Projection updates are always asynchronous

Structure your application to work naturally with eventual consistency:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.ReadModels;
[EventType]
public record EcBookCreated(string Title, string Author);
public record EcBookInventory(Guid Id, string Title, string Author);
public class EcBookService(IEventLog eventLog, IReadModels readModels)
{
// Good — fire and forget: don't wait for the projection before returning
public async Task<EventSourceId> CreateBook(string title, string author)
{
var bookId = EventSourceId.New();
await eventLog.Append(bookId, new EcBookCreated(title, author));
return bookId;
}
// Problematic — expecting immediate consistency
public async Task<EcBookInventory> CreateBookAndReturn(string title, string author)
{
var bookId = EventSourceId.New();
await eventLog.Append(bookId, new EcBookCreated(title, author));
// The projection may not have run yet — this can return a stale or default instance
return await readModels.GetInstanceById<EcBookInventory>(bookId);
}
}

Chronicle provides a .Watch<TReadModel>() API that allows you to observe projection changes in real-time:

using System.Reactive.Linq;
using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.ReadModels;
[EventType]
public record EcWatchBookCreated(string Title, string Author);
public record EcWatchBookInventory(Guid Id, string Title, string Author);
public class EcWatchBookService(IEventLog eventLog, IReadModels readModels)
{
public IObservable<ReadModelChangeset<EcWatchBookInventory>> WatchBookChanges() =>
readModels.Watch<EcWatchBookInventory>();
public async Task CreateBookAndWatch(string title, string author)
{
var bookId = EventSourceId.New();
// Subscribe before appending so the update is observed once the projection catches up
using var subscription = readModels.Watch<EcWatchBookInventory>()
.Where(changeset => changeset.ModelKey.Value == bookId.Value)
.Subscribe(changeset => Console.WriteLine($"Book projection updated: {changeset.ReadModel?.Title}"));
await eventLog.Append(bookId, new EcWatchBookCreated(title, author));
}
}

For applications that need to respond to database changes from external sources, you can:

  • Use database change streams: Most modern databases (MongoDB, PostgreSQL, SQL Server) provide change stream APIs
  • Implement polling mechanisms: Periodically check for changes using timestamps or version fields
  • Leverage Chronicle’s watch API: Use the .Watch<TReadModel>() method to observe projection updates regardless of their source

Separate operations that create data from those that read data:

using Cratis.Chronicle.Events;
using Cratis.Chronicle.EventSequences;
using Cratis.Chronicle.ReadModels;
[EventType]
public record EcCqsBookCreated(string Title);
public record EcCqsBook(Guid Id, string Title);
// Commands — fire and forget, never return projected state
public class EcCqsBookCommandHandler(IEventLog eventLog)
{
public Task Create(EventSourceId bookId, string title) =>
eventLog.Append(bookId, new EcCqsBookCreated(title));
}
// Queries — always read from projections
public class EcCqsBookQueryHandler(IReadModels readModels)
{
public Task<EcCqsBook> GetBook(EventSourceId bookId) =>
readModels.GetInstanceById<EcCqsBook>(bookId);
}
  • Accept that reads may be slightly stale
  • Use optimistic UI updates where possible
  • Implement retry logic for critical consistency requirements
  • Test your application behavior during projection lag
  • Verify retry mechanisms work correctly
  • Ensure UI handles missing data gracefully

Eventual consistency in Chronicle projections provides excellent performance and scalability while requiring thoughtful application design. By understanding the asynchronous nature of projection updates and implementing appropriate patterns, you can build robust applications that work naturally with Chronicle’s event-driven architecture.

Remember: eventual consistency is a feature, not a limitation. It enables the high-performance, scalable systems that Chronicle is designed to support.