
Marten and Polecat are able to support a lot more options for data consistency than any of their commonly used competitor tools and that can be highly advantageous for trying to build effective and performant systems using event sourcing
Bring up event sourcing with almost any development team and you'll hear the same objection within the first five minutes: "but then our queries are eventually consistent, right? The user saves something and the next screen shows stale data?" And for most of the event sourcing world, the honest answer is yes — eventual consistency is a mandatory tax you pay on day one, forever, whether or not your system actually needs it.
I literally had that exact conversation today doing a training workshop for a JasperFx client!
Here's the thing though, it doesn't actually have to be that way. It's usually an artifact of a specific architectural choice most event sourcing tools made — putting your events in one specialized database and your projected read models in a different database, with an asynchronous process of some sort shuffling data between them. Once you've split the write side and the read side across two systems, no amount of cleverness gets you a transactional guarantee back. The best you can do is make the lag small and give people ways to cope.
Okay, technically you could try to use distributed transactions, but c'mon, nobody wants to do that!
Marten and Polecat made the other choice: events and read models live in the same database. PostgreSQL for Marten, SQL Server 2025 for Polecat. That one decision is the foundation for everything in this post, because it means consistency stops being a fixed property of your architecture and becomes a dial you can turn — per projection, and adjustable later when you know more.
In this post we'll walk through what that dial gives you:
- Inline projections that snapshot your read model in the same database transaction as the event append — genuine strong consistency, which we believe is close to unique among event sourcing tools
FetchLatest(), which gives you the current state of a projected aggregate even when that projection runs asynchronously and the background daemon is lagging- Wait-for-non-stale queries when you need an async projection to be caught up before you read it
FetchForWriting(), the command-side abstraction that makes projection lifecycles a configuration detail you can change later instead of an architectural commitment you're stuck with- Push-based updates through Marten/Polecat's first class Wolverine integration, so "eventually consistent" becomes "the UI updates itself the moment the projection does"
- Test automation for asynchronous projections, because if you've ever tried to write a reliable integration test against an eventually consistent read model, you have the scars to know why this matters
And along the way we'll compare, carefully and with receipts, against three other significant players in the event sourcing space: KurrentDB (formerly EventStoreDB), Axon, and Cratis Chronicle obviously, the first two are the key players in the event sourcing space, but the Cratis folks are grinding away right now and well, game recognizes game?.
Why everyone else is eventually consistent by construction
Let's start with the architecture that produces the tax.
KurrentDB is a purpose-built event store, and a good one. But it stores events — your read models live somewhere else. Kurrent's own documentation describes catch-up subscriptions as the mechanism "typically used for producing read models," projecting event payloads "to a piece of state in another database," and their tutorial series walks you through projecting into PostgreSQL and Redis. Keeping track of where your subscriber is in the event stream — the checkpoint — is, in their docs' own words, "a sole responsibility of the subscriber." KurrentDB does have a built-in server-side projections engine (JavaScript running inside the database), but its output is more events and streams inside KurrentDB, not queryable documents, and the same docs page steers you away from it: many problems "are better served by hosting another read model populated by a catchup subscription." So the canonical KurrentDB system is two databases, an application-maintained checkpoint, and eventual consistency between them by construction. There is no API to wait for a read model to catch up; the community recipe is to store the log position in your read model and poll-and-retry after a write.
Axon splits the command side and query side by design, with projections built by event processors. The default processor in Axon Framework 4 is the Tracking Event Processor and in Axon 5 it's the Pooled Streaming Event Processor — and both, per the docs, run on their own threads, pulling events after the publishing transaction "must have been committed." Your projection lives in its own RDBMS or Mongo store with a tracking token stored alongside it. Axon does have subscribing event processors that run in the publishing thread, and if — and only if — your event store is a JPA/JDBC store in the same database as your projections, that can give you a genuinely transactional read model. But that path is explicitly not the default, loses replay and parallelization, and doesn't compose with Axon Server, their flagship event store: there's no distributed transaction between an Axon Server append and your projection database. AxonIQ says it themselves: "the query handling side is eventually consistent. You cannot base decisions on the command handling side by using the query side."
Cratis Chronicle is an ambitious newer entrant — a standalone event store server built on Microsoft Orleans, talking to your app over gRPC, storing events in MongoDB by default. Its own documentation is admirably direct about the model: projections "may lag behind events" and projection updates "are always asynchronous." Chronicle's strong-consistency answer is replay-on-read — GetInstanceById rebuilds the read model from the event log at query time, which their docs correctly note has a cost that "grows linearly with the number of events in the history." The docs also describe an "immediate projections" mode that would materialize synchronously on append, but as of this writing that documentation page is marked as in progress and the shipped mechanism is the query-time replay. There is no transaction spanning the event append and a stored read model update — the append completes, then Orleans grains observe and react.
Notice what all three have in common. It's the topology: the moment events and read models live in different systems, eventual consistency stops being a choice and becomes a law of physics. Everything downstream — the subscription queries, the checkpoint bookkeeping, the "how do we hide the lag from users" patterns — is coping machinery for a constraint the architecture created.
One database changes the physics
Marten runs your event store and your document database and your projected flat tables in a single PostgreSQL database. Polecat does the same on SQL Server 2025, leaning on its native JSON type. Your events go in an events table; your projected read models are documents or plain SQL tables sitting a few schemas away; and — this is the part that matters — a single database transaction can span both.
That co-location is worth dwelling on for the deployment story alone, before we even get to consistency:
- One database to provision, monitor, back up, and secure. Compare the canonical KurrentDB or Axon Server topology: a specialized event store cluster, plus PostgreSQL/Mongo/Elastic for read models, plus the subscription or processor infrastructure keeping them in sync. Every box in that diagram is something your ops team deploys, patches, and reasons about at 3am during an incident.
- It's a database you already have. PostgreSQL is ubiquitous — every cloud provider offers a managed flavor, your platform team already knows how to run it, and
docker run postgresgets a new developer a full local environment in seconds. The same holds for SQL Server in the .NET shops Polecat is aimed at. No new specialized infrastructure to get through procurement, no new operational skill set to build. No novel database engines to get past your DBAs. - One backup is a consistent backup. When events and read models are in one database, a point-in-time restore restores a coherent system. When they're in two systems, restoring them to the same logical moment is your problem.
And then there's the consistency dial.
Projection lifecycles: the dial itself
Every projection in Marten and Polecat is registered with a ProjectionLifecycle — the same three options in both tools:
| Lifecycle | What happens | Consistency |
|---|---|---|
Inline | The projected document is updated in the same transaction that appends the events | Strong |
Live | The aggregate is built on demand, in memory, from the raw events | Strong (always current) |
Async | A background daemon continuously updates the stored documents | Eventual — with escape hatches |
Registration is one line per projection, and different projections in the same application happily run with different lifecycles:
builder.Services.AddMarten(opts =>
{
opts.Connection(connectionString);
// Snapshot the Order aggregate in the same transaction
// as its events. Strong consistency.
opts.Projections.Snapshot<Order>(SnapshotLifecycle.Inline);
// A cross-cutting dashboard view that can lag a little?
// Run it in the background.
opts.Projections.Add<DailyOrderStatsProjection>(ProjectionLifecycle.Async);
});The Inline lifecycle is the one we want to make sure you fully appreciate, because we believe it's close to unique in the event sourcing world. When you call SaveChangesAsync() on a session that appended events, Marten applies the inline projections and writes the resulting documents in the same database transaction as the events themselves. Not "very quickly afterward." Not "within milliseconds under normal load." The same transaction. Either the events and the updated read model both commit, or neither does. There is no window — none — in which the events exist but the snapshot doesn't.
Polecat works exactly the same way: its docs describe the Inline lifecycle as projections running "in the same transaction as the event append," with the read model "always up to date."
Think about what this means for the standard objection from the top of the post. A user submits a command, your handler appends an OrderShipped event, and the Order read model is transactionally current before the HTTP response leaves your server. The next screen queries the document and it is simply correct. No subscription lag, no polling, no "refresh in a second and it'll show up." For the very large class of systems whose write volume doesn't demand async processing — which, honestly, is most business systems — you can do event sourcing with the exact same read-after-write behavior your team is used to from a plain CRUD application.
None of the three tools above can offer this, because none of them can put your read model in the same transaction as your events. Kurrent's events and your Postgres read models are different databases. Axon's default processors run after the commit, and their same-transaction option requires abandoning Axon Server for a JPA event store co-located with your projections — at which point you've rebuilt a worse version of the Marten architecture. Chronicle's appends return before any projection has run, full stop.
FetchForWriting: consistency for the command side
So far we've been talking about the query side. The command side has its own consistency story, and it's built around one abstraction: FetchForWriting().
public static async Task Handle(
ShipOrder command,
IDocumentSession session)
{
// Fetch the current state of the Order aggregate,
// with optimistic concurrency checking against the
// version the client was working from
var stream = await session.Events
.FetchForWriting<Order>(command.OrderId, command.Version);
if (stream.Aggregate.Status != OrderStatus.Ready)
throw new InvalidOperationException("Order isn't ready to ship");
stream.AppendOne(new OrderShipped(DateTimeOffset.UtcNow));
await session.SaveChangesAsync();
}FetchForWriting() gives you the current aggregate state to make decisions against, and enforces optimistic concurrency: if another process sneaks events into that stream between your fetch and your SaveChangesAsync(), Marten throws a ConcurrencyException instead of letting you write decisions based on stale state. If you need harder guarantees, FetchForExclusiveWriting() takes an actual row lock on the stream (a PostgreSQL row lock in Marten; UPDLOCK HOLDLOCK in Polecat) so competing commands queue up instead of colliding.
And if you're using Wolverine, even this ceremony disappears into the aggregate handler workflow, which is built directly on top of FetchForWriting():
public static class ShipOrderEndpoint
{
[WolverinePost("/api/orders/{orderId:guid}/ship")]
public static OrderShipped Post(
ShipOrder command,
// Wolverine generates the FetchForWriting() call,
// version checking, and SaveChangesAsync() around you
[WriteAggregate("orderId")] Order order)
{
if (order.Status != OrderStatus.Ready)
throw new InvalidOperationException("Order isn't ready to ship");
return new OrderShipped(DateTimeOffset.UtcNow);
}
}That's a fully event-sourced, optimistically-concurrent command handler, and the only code you wrote is the business logic.
The part people miss: FetchForWriting makes lifecycles reversible
Most event store tools depend on what Marten and Polecat call the Live lifecycle for giving you consistent "write model" state for command handlers, which certainly works as long as you keep your event streams short, but that choice can absolutely lead to performance problems as your event store gets bigger
Here's the strategic payoff of that abstraction, and it's the point we most want you to take away from this post.
Your command handlers say "give me the current Order" — they do not say how. Behind FetchForWriting() (and its read-only sibling FetchLatest() that's optimized for read-side queries), Marten does whatever the projection's registered lifecycle requires:
Live: replay the stream's events into an in-memory aggregate, which can be a great choice if your event streams (or DCB queries) are relatively shortInline: load the transactionally-maintained document — a single row readAsync: load the persisted snapshot, then fetch any events the daemon hasn't applied yet and advance the aggregate in memory — so you get current state even while the daemon lags
Which means the lifecycle decision is not baked into your code. It's one line of configuration, and you can change it later — after you have real production data about stream lengths, write contention, and query patterns — without touching a single command handler.
We can't overstate how much this de-risks the up-front modeling that makes teams nervous about event sourcing. The classic horror story goes like this: six months in, you discover that a stream you expected to hold twenty events holds twenty thousand. The long-running order. The IoT device that reports every minute. The insurance claim that stays open for a year. In most event sourcing tools, that's a rehydration performance cliff — every command now replays the whole history — and the fixes are all invasive: redesign your stream boundaries, split streams and migrate history, or hand-build snapshot infrastructure and the cache-invalidation headaches that come with it. (Chronicle's docs, to their credit, admit this directly: query-time replay cost "grows linearly" with history, and histories "that run into thousands of events per instance can become too slow for interactive use.")
And for the record, Marten and Polecat both support DCB style event sourcing that many will claim is the answer for the problems in the previous paragraph
In Marten? You change the registration from Live to Inline — or to Async with FetchLatest() — and now commands read a snapshot document plus at most a handful of trailing events, regardless of how long the stream grows. Flat performance on ten events or a hundred thousand. Your handlers don't change, your tests don't change, and nobody redesigns streams under deadline pressure. Event sourcing with Marten is simply more forgiving of the modeling decisions you got wrong before you could have known better — and everyone gets some of them wrong.
FetchLatest() deserves one more beat of attention on its own. Even for projections you've deliberately moved to the Async lifecycle for write throughput, FetchLatest<Order>(id) is not eventually consistent — it reads the latest persisted snapshot and applies any not-yet-projected events on top, in memory, before handing you the result. You've kept async's write-side benefits and still get read-your-own-writes for single-stream aggregates. To our knowledge, no other event sourcing tool has an equivalent — it's only possible because the snapshot and the events it might be missing live in the same database, one query away from each other.
Living well with Async, when you choose it
Sometimes you genuinely want asynchronous projections — heavy multi-stream aggregations, projections with expensive enrichment, or hot streams where you don't want projection work inside the write path. Fine! The difference with the Critter Stack is that choosing async doesn't mean losing control of freshness. You get three distinct mechanisms.
1. Wait for the daemon when it matters. Both Marten and Polecat can block until async projections have caught up to the events that existed when you asked:
// Store-wide: wait (up to a timeout) for all async projections
// to reach the current high water mark
await store.WaitForNonStaleProjectionDataAsync(5.Seconds());Or scope it to a single query — "I'd rather this dashboard query take 200ms longer than show stale numbers":
// Marten
var trips = await session.QueryForNonStaleData<Trip>(5.Seconds())
.Where(x => x.State == "Texas")
.ToListAsync();// Polecat
var orders = await session.Query<OrderSummary>()
.QueryForNonStaleData()
.Where(x => x.Status == OrderStatus.Open)
.ToListAsync();Contrast that with the state of the art elsewhere: KurrentDB has no such API — the community answer is hand-rolled polling against log positions you store in your own read model. Axon has no supported wait-for-catch-up primitive either; the GitHub feature request asking for exactly this has been open since 2020. Chronicle is actually the closest here — its AppendResult.WaitForCompletion() will wait for observers to process a specific append — though it's a wait on the append side rather than a guarantee on an arbitrary later query.
2. Push the fresh state instead of making anyone poll. This is where Marten and Polecat's first class Wolverine integration turns eventual consistency from "poll and hope" into a push model. Projections can raise side effects — and our users lean on this constantly to broadcast newly updated aggregate state to user interfaces over SignalR, or to publish the updated read model as a Wolverine message the moment the projection applies new events:
public class OrderSummaryProjection : SingleStreamProjection<OrderSummary, Guid>
{
public override ValueTask RaiseSideEffects(
IDocumentOperations ops, IEventSlice<OrderSummary> slice)
{
if (slice.Aggregate != null)
{
// Publish the freshly updated read model through Wolverine --
// a handler pushes it to the browser via SignalR, or relays it
// to any other service that cares
slice.PublishMessage(new OrderSummaryUpdated(slice.Aggregate));
}
return new ValueTask();
}
}The consuming screen doesn't ask "is my data stale?" — the fresh data shows up when it exists. Axon's subscription queries chase the same experience, but require you to modify every projection event handler to call QueryUpdateEmitter.emit(), take a reactive-streams dependency on the client, and manage subscription lifecycles by hand. Chronicle's observable "watch" queries are genuinely nice in the same spirit — though as their own docs frame it, that's a mitigation that tells you when the projection caught up, which is exactly our point: push is the humane way to consume the async lifecycle, and in the Critter Stack it's a first class projection hook rather than infrastructure you assemble.
3. Keep using the same read APIs. And of course, FetchLatest() from the previous section works against async projections too. The three mechanisms compose — snapshot reads that self-correct, waits when you need a hard guarantee, pushes for live UIs.
Testing asynchronous projections without the misery
This feature is a great illustration of why you don't really want to just roll your own event store for serious work. This testing helper has taken a lot of feedback from the community and improvement to make this robust in a variety of circumstances
Let's be honest about why this section exists: integration tests against eventually consistent read models are miserable. Anyone who has built them on other stacks knows the drill — append events, then Task.Delay(500), then assert, then watch it flake in CI, then bump it to Task.Delay(2000), then watch the suite crawl. The test isn't wrong; the architecture just gave it nothing deterministic to wait on.
Because the Critter Stack owns the whole pipeline — events, daemon, projections, and read models in one place — it can give you real synchronization points, and we've invested heavily in exactly that. We haven't seen anything comparable elsewhere in the event sourcing world.
The workhorse is the same WaitForNonStaleProjectionDataAsync() you just saw, which turns the flaky sleep-and-pray test into a deterministic one:
[Fact]
public async Task daily_stats_reflect_shipped_orders()
{
await using var session = theStore.LightweightSession();
session.Events.StartStream<Order>(orderId, new OrderPlaced(...), new OrderShipped(...));
await session.SaveChangesAsync();
// Deterministic: block until the async daemon has caught up
// to everything appended above, or fail loudly on timeout
await theStore.WaitForNonStaleProjectionDataAsync(15.Seconds());
var stats = await session.LoadAsync<DailyOrderStats>(today);
stats.ShippedCount.ShouldBe(1);
}For focused projection tests, Marten ships an actual scenario harness — EventProjectionScenario() — that manages daemon startup and shutdown for you, batches event appends, and asserts against the projected documents, with the same scenario working across any lifecycle. Which means you can even write the test once and flip the projection between Inline and Async — remember, that's one registration line — without rewriting the test.
And it goes up a level: Wolverine's tracked session testing support uses Wolverine's internal instrumentation to know when all outstanding work from a message or HTTP call has completed, and it includes a PauseThenCatchUpOnMartenDaemonActivity() stage that drives every projection and subscription up to the current high water mark inside the tracked run:
var tracked = await theHost
.TrackActivity()
.PauseThenCatchUpOnMartenDaemonActivity()
.InvokeMessageAndWaitAsync(new ShipOrder(orderId, version));
// The command executed, cascaded messages completed, AND the
// async projections are caught up -- now assert with confidence
tracked.Sent.SingleMessage<OrderSummaryUpdated>()
.Summary.Status.ShouldBe(OrderStatus.Shipped);Round it out with MartenDaemonModeIsSolo() for fast, lock-free daemon startup in test hosts and Host.ResetAllMartenDataAsync() for clean state between tests, and asynchronous projections become something you test the way you test everything else — deterministically, in parallel, without a single Task.Delay in sight. If you've been burned before, we think this alone is worth the price of admission. (Which is free. MIT licensed.)
The scorecard
Pulling it all together — and being fair about the nuances we noted along the way:
| Capability | Marten / Polecat | KurrentDB | Axon | Cratis Chronicle |
|---|---|---|---|---|
| Events + read models in one database | Yes | No — read models in a separate database via catch-up subscriptions | Not with Axon Server; only via a JPA event store co-located with projections | No — separate server process, projections materialized asynchronously |
| Read model updated in the same transaction as the event append | Yes — Inline lifecycle | No | Only the non-default, discouraged subscribing-processor + shared-RDBMS path | No — appends return before projections run |
| Current aggregate state despite lagging async projection | Yes — FetchLatest() | Rebuild from stream yourself | Rebuild from stream yourself | Query-time replay (GetInstanceById), linear cost in stream length |
| Wait for projections to catch up before querying | Yes — WaitForNonStaleProjectionDataAsync() / QueryForNonStaleData() | No — DIY position polling | No — feature request open since 2020 | Partial — WaitForCompletion() on a specific append |
| Change a projection's consistency model without changing handlers | Yes — lifecycle is one registration line behind FetchForWriting()/FetchLatest() | N/A — read model pipeline is bespoke application code | Processor choice leaks into architecture | Modes differ at the query API level |
| Push updated read models to UIs/consumers | Yes — projection side effects into Wolverine → SignalR, messaging | DIY on subscriptions | Subscription queries — per-handler QueryUpdateEmitter wiring + reactive client | Yes — observable watch queries (strongest of the three here) |
| Deterministic test automation for async projections | Yes — wait APIs, EventProjectionScenario(), tracked sessions | No | No | WaitForCompletion() helps; no dedicated harness |
To be clear about what we're not saying: KurrentDB is a capable, mature event store, Axon is a serious framework with real strengths in the JVM ecosystem, and Chronicle is doing genuinely interesting work — its observable queries are a feature we tip our hat to. If you need a globally distributed, append-only log as shared infrastructure across many heterogeneous services, a dedicated event store has a real case. What we're saying is narrower and, we think, more important for most teams building .NET business systems: those tools cannot offer you strong consistency between your events and your read models, and Marten and Polecat can — along with the freedom to relax it selectively, projection by projection, when you actually need to.
Start strong, relax deliberately
Our advice to teams starting out with the Critter Stack is almost boringly pragmatic: start with Inline snapshots and FetchForWriting(). You get event sourcing's audit trail, temporal queries, and modeling benefits with the same read-your-own-writes behavior as the CRUD app you're replacing — no eventual consistency conversation with your product owner required. One database in your deployment diagram, and it's PostgreSQL or SQL Server, which your team already knows how to run.
Then, when the profiler — not the architecture astronaut — tells you a particular projection should move to the background, change one line. Your handlers won't notice. Your tests, thanks to the daemon-aware test automation, will keep passing deterministically. And your users, thanks to FetchLatest(), wait-for-non-stale queries, and SignalR pushes fed by projection side effects, may never notice either.
That's the whole pitch: everyone else hands you eventual consistency as a mandatory tax and wishes you luck. Marten and Polecat hand you a dial.
Where to go from here
Documentation for everything above:
- Marten projection lifecycles and Inline projections
- Reading aggregates with
FetchLatestand the command handler workflow withFetchForWriting - Marten's async daemon, including the wait-for-non-stale APIs, and testing projections
- Polecat projections, appending events, and the Polecat async daemon
- Wolverine's aggregate handler workflow and integration testing with tracked sessions
And from us: if you're weighing event sourcing options right now — or living with a two-database read model pipeline that's hurting — this is exactly the kind of architectural decision our consulting services exist for, and JasperFx support plans put the people who wrote Marten, Polecat, and Wolverine on call for you. Or just come argue with us about consistency models in the Critter Stack Discord — we're there most days.

