Guides

Save hooks and global defaults

Fill unset properties on every matching entity, and see or change the final row list before it is saved.

Global defaults

Several entity types may share a convention, such as recording who created them. Repeating that default in every blueprint makes it easier for the definitions to drift. options.Default<TBase, TProp> applies it to entities matching a base class or interface. It runs after blueprint Derives and fills only unset properties.

TestBiome.cs
private static readonly BiomeOptions<ZeeqSeed> Options =
    new BiomeOptions<ZeeqSeed>()
        .SeedEntities(seed => [seed.Owner, seed.Organization])
        .Default<IUpdatedEntity, DateTimeOffset>(
            e => e.UpdatedAtUtc,
            (_, ctx) => ctx.Now
        );

From the library's CustomSeed tests: every Note implements IHasCreatedBy, so ownership comes from the seed and an explicit value still wins.

// Default<IHasCreatedBy, string?>(n => n.CreatedBy, (_, ctx) => ctx.Seed.Actor)
var options = TestBiome.NewOptions();
await TestBiome
    .Start(out var seed, store, options)
    .AddNotes(out var notes, _ => { }, n => n.CreatedBy = "custom")
    .BuildAsync();
// notes[0].CreatedBy == seed.Actor; notes[1].CreatedBy == "custom"
  • Default needs explicit type arguments (Default<TBase, TProp>).
  • The selected property must be directly writable. init properties are fine. Anything else throws ArgumentException.
  • Defaults apply to entities built by blueprints, including the output of an input blueprint, but not its input object. They don't reach aggregate parts or rows added in BeforeSave.
  • A default is fill-only: it treats the type's default value (null, 0, false) as unset, so it cannot tell "unset" from an explicit default value.

Save hooks

Some setup rules depend on the whole graph, such as adding an audit row before saving. Save hooks give those rules a place to run: options.BeforeSave can inspect and change the final row list, while options.AfterSave runs after a successful save.

TestBiome.cs
.BeforeSave((save, _) =>
{
    // Invariant: every saved user is an active member of the seed organization.
    foreach (var user in save.Entities.OfType<User>().ToList())
    {
        save.Entities.Add(new OrganizationMembership
        {
            Id = $"mem_{Guid.CreateVersion7():N}",
            OrganizationId = save.Seed.Organization.Id,
            UserId = user.Id,
            Role = user == save.Seed.Owner ? "owner" : "member",
            Status = MembershipStatus.Active,
            CreatedByUserId = save.Seed.Owner.Id,
        });
    }
    return ValueTask.CompletedTask;
})
// e.g. logging, assertions on what was saved
.AfterSave((save, _) => ValueTask.CompletedTask)
MemberMeaning
save.EntitiesThe mutable List<object> about to be saved (seed rows first, then created entities).
save.SeedYour seed.
save.SessionThe session, for inspecting what was created (All<T>, Last<T>).

Hooks run once per BuildAsync, in registration order, and AfterSave only runs when the store's save succeeded.

A hook in action

The library's CustomSeed test registers an audit row in BeforeSave and records the order of events:

CustomSeedTests.cs
var options = TestBiome
    .NewOptions()
    .BeforeSave(
        (ctx, _) =>
        {
            events.Add("before");
            ctx.Entities.Add(new Audit(ctx.Seed.Actor));
            return ValueTask.CompletedTask;
        }
    )
    .AfterSave(
        (ctx, _) =>
        {
            events.Add("after");
            events.Add(ctx.Session.Last<Note>().Text);
            return ValueTask.CompletedTask;
        }
    );
var biome = TestBiome.Start(out var seed, store, options);

await biome
    .AddNotes(out var notes, _ => { }, n => n.CreatedBy = "custom")
    .BuildAsync();
// events: "before,save,after,note"; the Audit row reached the store

When a hook beats a default

An EF example from the library: open pull requests are queued for review by a coordinator row, as an app's own save rules might do. Putting the rule in BeforeSave keeps every test honest without repeating it.

Reviews/Blueprints.cs
private static readonly BiomeOptions<ReviewSeed> Options =
    new BiomeOptions<ReviewSeed>()
        .SeedEntities(seed => [seed.Owner, seed.Organization])
        .BeforeSave(
            (save, _) =>
            {
                save.Entities.AddRange([
                    .. save
                        .Entities.OfType<PullRequestRecord>()
                        .Where(pr => !pr.Closed)
                        .Select(pr => new ReviewQueueEntry
                        {
                            PullRequest = pr,
                        }),
                ]);
                return ValueTask.CompletedTask;
            }
        );
A hook can keep a shared save rule in one place, but hidden rows make a test harder to follow. Keep hooks small and use them for rules the app consistently requires. Rows added in BeforeSave skip traits, Derives, and global defaults, so the hook must supply every required property.