Skip to content

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.

demonstrateswriter
ProductCatalogSampleAsOf, AllVersions, History<T>, delete tombstonesHistoryWriter.Interceptor (the default)
TaskTrackerSamplewho changed a row and why, and what a raw ExecuteUpdate leaves behindHistoryWriter.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 ​

bash
dotnet run --project samples/ProductCatalogSample

A 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:

cs
// 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:

cs
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 ​

bash
dotnet run --project samples/TaskTrackerSample

Two 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:

cs
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:

cs
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):

cs
// 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 ​

Released under the MIT License.