Skip to content

CRUD, EF Core, and Chronicle

If your instinct is to add a table, map an entity, and call SaveChanges(), you already have useful muscle memory. This page maps the CRUD/EF Core model onto Chronicle so the differences are explicit.

In CRUD you store the current state and overwrite it; in Chronicle you store what happened and derive the current state from those facts. The current state still exists — it’s a read model — you just build it from events instead of editing it in place.

You know (CRUD / EF Core)In Chronicle
A table / entityAn event source and its stream of events
INSERT a rowAppend a “created” event
UPDATE a columnAppend an event describing what changed (e.g. AddressChanged)
DELETE a rowAppend a “removed/closed” event — history is never erased
DbContext.SaveChanges()EventLog.Append(...)
SELECT / LINQ queryA query over a read model built by a projection
A computed/denormalized viewA purpose-built read model — make as many as you need
ALTER TABLE / EF migrationEvent type migration + replay the projection
Optimistic concurrency tokenConstraints and the event stream’s ordering
  • You still use C# records and dependency injection.
  • You still query data and render it — the read side looks like querying a collection.
  • You can still keep a relational/document database for the genuinely-CRUD parts of your app; Chronicle doesn’t demand all-or-nothing.
  • You model verbs, not just nouns. Instead of one mutable Customer row, you record CustomerRegistered, AddressChanged, AccountClosed. Each is an immutable fact. This is the part that feels new — and it’s where the value (audit, history, replay) comes from.
  • Reads are eventually consistent — by default. A projection materializes the read model after the event is appended, so a stored read immediately after a write may lag by a moment. Usually fine; occasionally something to design around. When read-after-write matters, Chronicle can also compute a read model on demand with strong consistency — see Read model consistency.
  • You don’t write update statements. A projection declares how events map onto a read model; Chronicle keeps it current. No UPDATE, no merge logic.
  • You don’t delete history. “Delete” becomes an event. For real erasure obligations (GDPR), see Compliance.

CRUD: update a row, then read it back. This is illustrative EF Core pseudocode, not a compiled example:

customer.Address = newAddress;
await db.SaveChangesAsync();
var current = await db.Customers.FindAsync(id);

Chronicle: the same change is a fact you append and a read side you declare. First, define the verbs as event types:

using Cratis.Chronicle.Events;
[EventType]
public record CrudComparisonCustomerRegistered(string Name, string Address);
[EventType]
public record CrudComparisonAddressChanged(string Address);

Then declare the read side. This is the projection — and notice it is not a 1:1 copy of the CRUD Customer entity. It’s shaped for the screen that reads it, and it answers something the overwritten row never could:

using Cratis.Chronicle.Keys;
using Cratis.Chronicle.Projections.ModelBound;
[FromEvent<CrudComparisonCustomerRegistered>]
[FromEvent<CrudComparisonAddressChanged>]
public record CrudComparisonCustomerCard(
[Key] Guid Id,
string Name,
string Address,
[Count<CrudComparisonAddressChanged>] int TimesRelocated);

[FromEvent<T>] (@fromEvent in TypeScript) maps event properties onto the record by name — the name arrives with CustomerRegistered, and the address is kept current by every AddressChanged. The counter tracks the moves. There is no UPDATE statement anywhere: Chronicle derives the card from the facts.

Now the write and the read-back:

using Cratis.Chronicle.Events;
public class CrudComparisonCustomerAddressUpdater(IEventStore eventStore)
{
public async Task<CrudComparisonCustomerCard> ChangeAddress(EventSourceId customerId, string newAddress)
{
await eventStore.EventLog.Append(customerId, new CrudComparisonAddressChanged(newAddress));
return await eventStore.ReadModels.GetInstanceById<CrudComparisonCustomerCard>(customerId);
}
}

GetInstanceById computes the card from its events on demand, so this read already reflects the append above — TimesRelocated included, a fact the CRUD row lost the moment SaveChanges ran.

Read When to use event sourcing for an honest take — there are domains where plain CRUD is the right answer, and that’s fine.

  • Get started — scaffold and run in minutes.
  • Tutorial — build a small system from events up.