Table of Contents

Cookbook — auditing: who and when

Full write attribution — created/updated/deleted, by whom, at what time — from conventions alone. No interceptors to write, no per-repository code, identical on all six stores.

The recipe

// 1. The entity: DataModel base classes give the canonical shape…
public sealed class Contract : EntityHistoryDataBase, IEntity<Guid>
{
    public string Title { get; set; } = "";
    // inherits: CreatedAt/CreatedById, UpdatedAt/UpdatedById, DeletedAt/DeletedById
}

// …or implement the interfaces + named properties directly (same effect):
public sealed class Contract : IEntity<Guid>, IEntityTimeMark, IEntityTimeTrack, IEntityTimeEnded
{
    public Guid Id { get; set; }
    public string Title { get; set; } = "";
    public DateTime CreatedAt { get; set; }     public Guid? CreatedById { get; set; }
    public DateTime? UpdatedAt { get; set; }    public Guid? UpdatedById { get; set; }
    public DateTime? DeletedAt { get; set; }    public Guid? DeletedById { get; set; }
    public Guid GetKey() => Id;
    public void SetKey(Guid key) => Id = key;
}
// 2. The conventions — the clock and the current user, resolved per request:
services.AddSingleton(new DataConventions
{
    Clock = TimeProvider.System,
    CurrentUserId = sp => (sp.GetService(typeof(ICurrentUser)) as ICurrentUser)?.Id,
});

What now happens, everywhere

Operation Stamps
Add + commit CreatedAt, CreatedById
Modify/Merge + commit UpdatedAt, UpdatedById
Remove + commit DeletedAt, DeletedById — and the row survives (soft delete)
DeleteManyAsync(filter) a set-based update stamping the same members, server-side

Reads exclude soft-deleted rows automatically; audit/restore screens read them deliberately:

var everything = await repo.GetFilteredAsync(
    c => c.DeletedAt != null,
    new QueryOptions<Contract>().IgnoringQueryFilters());

Testing it

Clock is a TimeProvider — freeze it and the whole lifecycle becomes deterministic:

var clock = new FakeTimeProvider(new DateTimeOffset(2026, 7, 22, 12, 0, 0, TimeSpan.Zero));
services.AddSingleton(new DataConventions { Clock = clock, CurrentUserId = _ => userId });
// ... Add + commit
Assert.That(contract.CreatedAt, Is.EqualTo(clock.GetUtcNow().UtcDateTime));
Assert.That(contract.CreatedById, Is.EqualTo(userId));

Beyond stamps — change history

Stamps answer who touched it last. For a full change log (what changed, old and new values), pair the stamps with an explicit history entity written in the same commit — the lean write model makes that natural, since your command handler already knows the change it is applying:

await contracts.ModifyAsync(contract);
await history.AddAsync(ContractChange.From(before, contract, userId));
await unitOfWork.CommitAsync();          // one flush — atomic on relational stores