Examples
Membership
Users, aliases, invitations and organizations - fan-out per parent, fill-only derives and an aggregate.
Membership setup combines several different needs: aliases under each user, expiry based on invitation time, and an organization created with its membership. This example shows when a child blueprint, a derived default, or an aggregate fits each choice.
Identity and membership: User with UserAlias children, PendingInvitation with a derived expiry, and an Organization built from an input as an aggregate (organization + membership). Source: tests/Zeeq.Biome.Tests/Membership.
The blueprints
Membership/Domain.cs
// Root entities use configured IDs; relationship-
// dependent fields belong in child factories.
internal static readonly Blueprint<MembershipSeed, User> User = Blueprint.For<
MembershipSeed,
User
>(ctx => new() { Id = ctx.NewId("user") });
// With returns a new immutable variant; it never mutates User.
internal static readonly Blueprint<MembershipSeed, User> VipUser = User.With(
u => u.IsVip = true
);
// Wire the typed user parent; leave NormalizedValue
// null so Derive fills only absent values.
internal static readonly ChildBlueprint<MembershipSeed, User, UserAlias> Alias =
Blueprint
.ChildOf<MembershipSeed, User, UserAlias>(
(user, _) => new() { User = user }
)
.Derive(a => a.NormalizedValue, (a, _) => a.Value.ToUpperInvariant());
// Expiry derives after customizers, so changing
// CreatedAt changes the default deadline.
internal static readonly Blueprint<
MembershipSeed,
PendingInvitation
> Invitation = Blueprint
.For<MembershipSeed, PendingInvitation>(ctx =>
new() { CreatedAt = ctx.Now }
)
.Derive(i => i.ExpiresAt, (i, _) => i.CreatedAt.AddDays(7));
Aliases fan out per user
Two users × two alias customizers = four aliases. An explicit NormalizedValue survives the fill-only Derive.
await TestBiome
.Start(out _)
.AddUsers(out var users, 2)
.AddAliases(
out var aliases,
a => a.Value = "Alice",
a =>
{
a.Value = "Bob";
a.NormalizedValue = "manual";
}
)
.BuildAsync();
// aliases.Length == 4; aliases[0].NormalizedValue == "ALICE";
// aliases[3].NormalizedValue == "manual"
var alice = new User { Id = $"user_{Guid.CreateVersion7():N}" };
var bob = new User { Id = $"user_{Guid.CreateVersion7():N}" };
var users = new[] { alice, bob };
var aliases = new List<UserAlias>();
foreach (var user in users)
{
aliases.Add(
new UserAlias
{
User = user,
Value = "Alice",
NormalizedValue = "ALICE",
}
);
aliases.Add(
new UserAlias
{
User = user,
Value = "Bob",
NormalizedValue = "manual",
}
);
}
db.AddRange(seed.Owner);
db.AddRange(users);
db.AddRange(aliases);
await db.SaveChangesAsync();
The normalization rule lives once, in the blueprint. Without Biome, each test restates
"ALICE" by hand, and the rule can drift.Derived expiry follows customized creation time
MembershipTests.cs
var created = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
await TestBiome
.Start(out _)
.AddInvitations(
out var invitations,
i => i.CreatedAt = created,
i =>
{
i.CreatedAt = created;
i.ExpiresAt = created.AddDays(1);
}
)
.BuildAsync();
// invitations[0].ExpiresAt == created.AddDays(7) (derived)
// invitations[1].ExpiresAt == created.AddDays(1) (explicit, kept)
Variants never mutate the original
MembershipTests.cs
await TestBiome
.Start(out _)
.AddOne(Blueprints.VipUser, out var vip)
.AddUser(out var ordinary)
// Keep the explicit empty normalized string:
// call site wins, nothing overwrites.
.AddAlias(out var alias, a => a.NormalizedValue = "")
.BuildAsync();
// vip.IsVip is true; ordinary.IsVip is false; alias.NormalizedValue == ""
An empty string counts as explicitly set, so
NormalizedValue = "" is kept, while leaving it null lets the Derive fill it. See Customizing.An organization as an aggregate
One input becomes an Organization plus its OrganizationMembership. At save, the parts replace the wrapper, after the seed owner:
Membership/Domain.cs
internal sealed record OrganizationGraph(
Organization Organization,
OrganizationMembership Membership
) : IBiomeAggregate
{
public IEnumerable<object> GetEntities() => [Organization, Membership];
}
internal static readonly InputBlueprint<
MembershipSeed,
OrganizationInput,
OrganizationGraph
> Organization = Blueprint.FromInput<
MembershipSeed,
OrganizationInput,
OrganizationGraph
>(
() => new(),
(input, ctx) =>
{
var org = new Organization { Slug = input.Slug };
return new(
org,
new()
{
Organization = org,
User = ctx.Seed.Owner,
IsDefault = input.IsDefault,
}
);
}
);
MembershipTests.cs
var store = new RecordingStore();
var biome = TestBiome.Start(out var seed, store);
await biome
.AddOrganizations(
out var graphs,
input =>
{
input.Slug = "alpha";
input.IsDefault = true;
},
input => input.Slug = "beta"
)
.BuildAsync();
// store.Entities: seed.Owner, graphs[0].Organization, graphs[0].Membership, …
// (5 rows, no wrappers)
// biome.All<OrganizationGraph>().Count == 2 (inspection keeps the wrapper)