Save hooks and global defaults
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.
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"
// The owner has to be set on every entity of every type, in every test.
var first = new Note
{
Id = $"note_{Guid.CreateVersion7():N}",
Number = 1,
CreatedBy = seed.Actor,
};
var second = new Note
{
Id = $"note_{Guid.CreateVersion7():N}",
Number = 2,
CreatedBy = "custom",
};
var comment = new Comment
{
Id = $"comment_{Guid.CreateVersion7():N}",
CreatedBy = seed.Actor,
}; // easy to forget
_db.AddRange(first, second, comment);
await _db.SaveChangesAsync();
Defaultneeds explicit type arguments (Default<TBase, TProp>).- The selected property must be directly writable.
initproperties are fine. Anything else throwsArgumentException. - 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.
.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)
| Member | Meaning |
|---|---|
save.Entities | The mutable List<object> about to be saved (seed rows first, then created entities). |
save.Seed | Your seed. |
save.Session | The 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:
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.
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;
}
);
BeforeSave skip traits, Derives, and global defaults, so the hook must supply every required property.