Samples
Two runnable console apps, each a self-contained tour of one side of Hindsight. Neither needs a database of your own - both start and tear down their own disposable PostgreSQL 17 container, so dotnet run is the whole setup.
| demonstrates | writer | |
|---|---|---|
ProductCatalogSample | AsOf, AllVersions, History<T>, delete tombstones | HistoryWriter.Interceptor (the default) |
TaskTrackerSample | who changed a row and why, and what a raw ExecuteUpdate leaves behind | HistoryWriter.Trigger |
The tutorial walks through the third sample project, InsuranceSample, step by step against an ASP.NET Core API; these two are shorter and meant to just be run.
Product catalog
dotnet run --project samples/ProductCatalogSampleA product's price changes twice after it's created. AsOf answers what it looked like at an earlier point in time, with no extra code at the call site:
// What did this product look like right after launch, before the sale?
var atLaunch = await db.Products.AsOf(priceChangedAt).SingleAsync(p => p.Id == widget.Id);
Console.WriteLine($"AsOf(right after launch): {atLaunch.Name} was {Money(atLaunch.Price)}, stock {atLaunch.StockQuantity}.");History<Product>() shows the same timeline with its validity windows and what changed:
Console.WriteLine();
Console.WriteLine("History<Product>() — every version with its validity window:");
await foreach (var v in db.History<Product>().Where(v => v.Entity.Id == widget.Id).AsAsyncEnumerable())
{
var to = v.IsCurrent ? "now" : v.ValidTo.ToString("O");
Console.WriteLine($" [{v.ValidFrom:O} .. {to}] {v.Operation,-6} {Money(v.Entity.Price),8}");
}The product is then deleted. It's gone from products - but History<Product>() still has the tombstone: when it was deleted and what it looked like right before.
Task tracker
dotnet run --project samples/TaskTrackerSampleTwo simulated users move a work item through its statuses. Each change is wrapped in DbContext.WithReason(...), so the audit trail carries who made it and why:
using (CurrentUser.Act("bob"))
using (db.WithReason("Picked it up"))
{
item.Status = WorkItemStatus.InProgress;
await db.SaveChangesAsync();
}
Console.WriteLine("bob moved it to InProgress.");
using (CurrentUser.Act("alice"))
using (db.WithReason("Reviewed and merged"))
{
item.Status = WorkItemStatus.Done;
await db.SaveChangesAsync();
}
Console.WriteLine("alice marked it Done.");History<WorkItem>() reads that trail back:
Console.WriteLine();
Console.WriteLine("History<WorkItem>() — who changed it, and why:");
await foreach (var v in db.History<WorkItem>().Where(v => v.Entity.Id == item.Id).AsAsyncEnumerable())
{
Console.WriteLine($" {v.Entity.Status,-11} by {v.ChangedByName ?? "(unknown)",-6} — {v.Reason}");
}Then a plain ExecuteUpdateAsync moves a batch of items directly in SQL - no SaveChanges, no interceptor anywhere in the call stack. HistoryWriter.Trigger catches it anyway, with NULL in every change-context column, since there's no SaveChanges call for a provider to attach one to (see Choosing a history writer):
// A couple more items, created without going through the change-context machinery above.
db.WorkItems.AddRange(
new WorkItem { Title = "Update the README", Status = WorkItemStatus.Todo, UpdatedAt = DateTimeOffset.UtcNow },
new WorkItem { Title = "Triage issues", Status = WorkItemStatus.Todo, UpdatedAt = DateTimeOffset.UtcNow });
await db.SaveChangesAsync();
// ExecuteUpdate bypasses SaveChanges (and the interceptor) entirely - Trigger mode still catches it.
var closed = await db.WorkItems
.Where(w => w.Status == WorkItemStatus.Todo)
.ExecuteUpdateAsync(s => s.SetProperty(w => w.Status, WorkItemStatus.Cancelled));
Console.WriteLine();
Console.WriteLine($"ExecuteUpdate cancelled {closed} Todo item(s) directly in SQL - no SaveChanges call.");Next steps
- Concepts: versions, periods and tombstones, in five minutes.
- Choosing a history writer: Interceptor vs. Trigger, in depth.
- Change context: store the user, the correlation id and the reason with every version.