Input blueprints
Blueprint.FromInput, and Blueprint.ChildFromInput for typed children, require a referenced library package that exposes those API forms. Check the API for the package selected by your project; if you use Zeeq.Biome.EntityFrameworkCore, use the matching package version. This documentation does not identify a public release for these forms.When the row can't be edited after it is built
A customizer runs after the factory has created the entity. That works while the test only assigns settable properties. It stops working when the property is init-only (including positional record properties), which a customizer cannot assign, or when one choice drives several values. A required member must be initialized by the factory, but if it has a setter a customizer can still set it.
An input blueprint moves customization earlier. The test customizes a small, settable input object. Then the factory reads the input and builds the row in one step. Your production model does not need to change to suit the tests.
For example, an academic term has an EndsOn date that tests never choose directly. They choose how many weeks the term lasts:
public sealed class AcademicTerm
{
public required string Id { get; init; }
public required string Name { get; init; }
public required DateOnly StartsOn { get; init; }
public required DateOnly EndsOn { get; init; }
}
// Construction data: settable, with nothing required.
public sealed class TermInput
{
public string? Name { get; set; }
public DateOnly StartsOn { get; set; }
public int Weeks { get; set; }
}
public static partial class Blueprints
{
public static readonly InputBlueprint<SchoolSeed, TermInput, AcademicTerm> Term =
Blueprint
.FromInput<SchoolSeed, TermInput, AcademicTerm>(
() => new(),
(input, ctx) =>
new()
{
Id = ctx.NewId("term"),
Name = input.Name!,
StartsOn = input.StartsOn,
EndsOn = input.StartsOn.AddDays(input.Weeks * 7),
}
)
.Derive(t => t.StartsOn, (_, ctx) => DateOnly.FromDateTime(ctx.Now.UtcDateTime))
.Derive(t => t.Weeks, (_, _) => 18)
.Derive(t => t.Name, (t, _) => $"{t.Weeks}-week term");
}
The factory initializes every required member, so the row is always valid. Derive fills input properties that are still unset, in declaration order, before the factory runs. A test then states only the choice it cares about:
await TestBiome
.Start(out _)
.AddOne(Blueprints.Term, out var halfTerm, t => t.Weeks = 9)
.BuildAsync();
var startsOn = DateOnly.FromDateTime(DateTime.UtcNow);
var halfTerm = new AcademicTerm
{
Id = $"term_{Guid.CreateVersion7():N}",
Name = "9-week term",
StartsOn = startsOn,
EndsOn = startsOn.AddDays(9 * 7),
};
halfTerm.EndsOn is nine weeks after StartsOn, and the test never mentioned a date. Both versions stay in memory, because neither saves anything.
Typed child inputs
A child can use an input too. Blueprint.ChildFromInput takes the input factory and a build function that receives the typed parent, the input, and the child context. It behaves like ChildOf: the child compiles only after its parent, and ctx.Ancestor<T>() reaches further up.
An enrollment row is immutable, and its seat number comes from a sequence:
public sealed class Enrollment
{
public required string Id { get; init; }
public required string SchoolId { get; init; }
public required string ClassId { get; init; }
public required int Seat { get; init; }
public required string Status { get; init; }
public required DateTimeOffset EnrolledAtUtc { get; init; }
}
public sealed class EnrollmentInput
{
public int Seat { get; set; }
public bool Waitlisted { get; set; }
public DateTimeOffset EnrolledAtUtc { get; set; }
}
public static readonly Sequence<int> EnrollmentSeats = Sequence
.Of(1, n => n + 1)
.PerParent(); // restarts for each class
public static readonly ChildInputBlueprint<
SchoolSeed,
SchoolClass,
EnrollmentInput,
Enrollment
> Enrollment = Blueprint
.ChildFromInput<SchoolSeed, SchoolClass, EnrollmentInput, Enrollment>(
() => new(),
(schoolClass, input, ctx) =>
new()
{
Id = ctx.NewId("enr"),
SchoolId = ctx.Ancestor<School>().Id, // grandparent
ClassId = schoolClass.Id, // typed parent
Seat = input.Seat,
Status = input.Waitlisted ? "waitlisted" : "active",
EnrolledAtUtc = input.EnrolledAtUtc,
}
)
.Derive(i => i.Seat, EnrollmentSeats)
.Derive(i => i.EnrolledAtUtc, (_, ctx) => ctx.Now);
Customizers and Derive work on EnrollmentInput, while out variables and the anchor hold the Enrollment rows. Fan-out is per parent, and a sequence reserves explicit seats across the whole step before it fills the rest:
await TestBiome
.Start(out _)
.AddDistrict(out _)
.AddSchool(out _)
.AddClass(out var algebra)
.Add(Blueprints.Enrollment, out var seated, 2) // seats 1, 2
.Under(algebra)
.AddOne(Blueprints.Enrollment, out var late, e => e.Waitlisted = true) // seat 3
.BuildAsync();
A customizer that needs the parent or the clock takes a second parameter. For a child input, that is the ChildContext, so ctx.Parent and ctx.Ago(…) are available:
.AddOne(
Blueprints.Enrollment,
out var early,
(e, ctx) => e.EnrolledAtUtc = ctx.Ago(TimeSpan.FromDays(3))
)
out holds the output. Add a handwritten context-aware overload only when tests use it repeatedly. See Generated grafts and scenario helpers.What an input is, and is not
An input is construction data. Biome does not record it, save it, or let it be a parent:
All<EnrollmentInput>()is always empty.out,.Under(…),.Transient(),All<Enrollment>()andLast<Enrollment>()all work on the outputs.- A root input blueprint stays a root after
.Under(…).Underdoes not fill its input or supply a parent. UseChildFromInputwhen the row needs a parent. - Global defaults (
options.Default) apply to the output row after the factory runs.Deriveon an input blueprint applies to the input.
Order of work in one Add step
A step builds all of its inputs before it builds any output, so a sequence can reserve every explicit value first:
- Each input is created, then its traits (
.With) and call-site customizers run. Derive(property, sequence)reserves every explicit value in the step.- For each input, in order: its
Derives fill unset input properties, then the factory builds the output. - Each output gets the global defaults, then is recorded with its parent link.
Input factories, derives, traits and customizers see only entities from earlier completed steps, through ctx.All<T>() and ctx.Last<T>(). Wire relationships through the typed parent and ctx.Ancestor<T>(), not through inspection.
Aggregates: one input, several rows
An input blueprint can also build a group of rows from one choice. When the output implements IBiomeAggregate, Biome saves its parts in place of the wrapper.
Creating an organization may require an organization row, a root team, and a membership. Tests usually care about the choice that creates the group, not each row separately.
public sealed class OrganizationInput
{
public string DisplayName { get; set; } = "Test Organization";
public string? Slug { get; set; }
public string Role { get; set; } = "owner";
public bool IsDefaultMembership { get; set; }
}
public sealed record OrganizationGraph(
Organization Organization,
Team RootTeam,
OrganizationMembership Membership
) : IBiomeAggregate
{
public IEnumerable<object> GetEntities() =>
[Organization, RootTeam, Membership]; // save order
}
public static partial class Blueprints
{
public static readonly InputBlueprint<
ZeeqSeed,
OrganizationInput,
OrganizationGraph
> Organization = Blueprint
.FromInput<ZeeqSeed, OrganizationInput, OrganizationGraph>(
() => new(),
(input, ctx) =>
{
var owner = ctx.Seed.Owner;
var org = new Organization
{
Id = ctx.NewId("org"),
DisplayName = input.DisplayName,
Slug = input.Slug,
CreatedByUserId = owner.Id,
};
var team = new Team
{
Id = ctx.NewId("team"),
OrganizationId = org.Id,
IsRootTeam = true,
};
var membership = new OrganizationMembership
{
Id = ctx.NewId("mem"),
OrganizationId = org.Id,
UserId = owner.Id,
Role = input.Role,
Status = MembershipStatus.Active,
IsDefault = input.IsDefaultMembership,
CreatedByUserId = owner.Id,
};
return new(org, team, membership);
}
)
.Derive(i => i.Slug, (_, ctx) => ctx.NewId("org"));
}
// Every test that needs an organization repeats the rows and their relationships.
var org = new Organization
{
Id = $"org_{Guid.CreateVersion7():N}",
DisplayName = "Beta",
Slug = "beta",
CreatedByUserId = seed.Owner.Id,
};
var team = new Team
{
Id = $"team_{Guid.CreateVersion7():N}",
OrganizationId = org.Id,
IsRootTeam = true,
};
var membership = new OrganizationMembership
{
Id = $"mem_{Guid.CreateVersion7():N}",
OrganizationId = org.Id,
UserId = seed.Owner.Id,
Role = "owner",
Status = MembershipStatus.Active,
IsDefault = true,
CreatedByUserId = seed.Owner.Id,
};
_db.AddRange(seed.Owner, seed.Organization, org, team, membership); // order matters
await _db.SaveChangesAsync();
await TestBiome
.Start(_db, out _)
.Add(
Blueprints.Organization,
out var orgs,
o => o.IsDefaultMembership = true,
o => o.Slug = "beta"
)
// saves org, root team, membership for each, in GetEntities() order
.BuildAsync();
At BuildAsync, aggregates are expanded recursively into their parts. Duplicate references are saved once, and a cycle in GetEntities() throws. The library's Membership tests show the effect: the owner (seed row) precedes the organization and membership parts.
All<T>() and Last<T>() include aggregate wrappers and transient entities, but not seed rows. The store never receives the wrapper itself. Parts are not tracked as parents, so a child of an aggregate takes the wrapper type as its TParent. Global defaults reach the wrapper, not its parts.Variants and transients
Input blueprints, root and child, support .With, .Derive and .AsTransient() like entity blueprints. A transient aggregate excludes all of its parts from the save.
var variant = Blueprints
.Organization.With(i => i.Slug = "trait")
.With((i, ctx) => i.IsDefaultMembership = ctx.Seed.Owner.Id == "owner")
.Derive(i => i.Slug, (_, _) => "fallback")
.AsTransient(); // none of the aggregate's parts are saved
Limits to keep in mind
Derive treats an input property's default value as unset. On a plain int or bool, an explicit 0 or false looks "not chosen", so a derive will fill it. On a nullable value type such as int? or bool?, an explicit 0 or false is kept, but an explicit null is still treated as unset and refilled if a Derive targets that property. Global defaults on the output can also replace null, 0 or false, because they fill by the same rule.- Biome does not copy values from an input to the output. The factory decides how each input property becomes part of the row.
- An input blueprint is more setup than a plain blueprint. Use one when the row has init-only properties that tests vary, when one choice drives several values, or when the output is a group of rows. For a single settable entity, customizing it directly is simpler.
- The default pipeline still applies to outputs: defaults are fill-only and run after the factory.