Zeeq.Biome
1.1.0
dotnet add package Zeeq.Biome --version 1.1.0
NuGet\Install-Package Zeeq.Biome -Version 1.1.0
<PackageReference Include="Zeeq.Biome" Version="1.1.0" />
<PackageVersion Include="Zeeq.Biome" Version="1.1.0" />
<PackageReference Include="Zeeq.Biome" />
paket add Zeeq.Biome --version 1.1.0
#r "nuget: Zeeq.Biome, 1.1.0"
#:package Zeeq.Biome@1.1.0
#addin nuget:?package=Zeeq.Biome&version=1.1.0
#tool nuget:?package=Zeeq.Biome&version=1.1.0
Zeeq Biome
Terse, readable test setup for .NET. A biome is your app's entity graph in miniature: just the rows one test needs, wired together correctly. You declare one blueprint per entity type that produces valid, uninteresting defaults. Tests then build scenarios as short fluent chains that mention only the properties under test. Parents and children are typed, numbering and ordering come from sequences, the clock is frozen, and persistence is pluggable (EF Core or none). Defaults never overwrite a value the test set.
await TestBiome.Start(_db, out var seed)
.AddCodeRepository(out var repo)
.AddPullRequest(out var pr, p => p.State = PullRequestState.Closed) // the property under test
.BuildAsync();
Install
dotnet add package Zeeq.Biome
dotnet add package Zeeq.Biome.EntityFrameworkCore # optional: save with a DbContext
Targets .NET 10. The core package has no dependencies. The extension-block examples use C# 14; generated graft methods use regular extension methods. Works with any test framework; the examples use TUnit.
Ordinary-entity input blueprints and typed child inputs are available in Zeeq.Biome 1.1.0 and later. Use the matching version of Zeeq.Biome.EntityFrameworkCore when needed.
Step by step
The examples model a code-review app: Organization, User, CodeRepository → PullRequestRecord → CodeReviewRecord → ActiveCodeReviewLock. Your entities are plain classes. Entities and parents must be reference types.
1. A seed and a TestBiome entry point
The seed is the baseline every test starts from: an owner, an organization, whatever your app always needs. It can be any type. Wrap Biome.Start once in your test project so tests can start with one call.
using Zeeq.Biome;
public sealed record ZeeqSeed(User Owner, Organization Organization)
{
public static ZeeqSeed Generate()
{
var owner = new User { Id = $"user_{Guid.CreateVersion7():N}", DisplayName = "Owner" };
var organization = new Organization
{
Id = $"org_{Guid.CreateVersion7():N}",
DisplayName = "Test Organization",
CreatedByUserId = owner.Id,
};
return new(owner, organization);
}
}
public static partial class TestBiome
{
private static readonly BiomeOptions<ZeeqSeed> Options = new BiomeOptions<ZeeqSeed>()
.SeedEntities(seed => [seed.Owner, seed.Organization]); // saved first, in this order
public static BiomeBuilder<ZeeqSeed, ZeeqSeed> Start(
out ZeeqSeed seed,
IBiomeStore? store = null,
TimeProvider? time = null)
{
seed = ZeeqSeed.Generate();
return Biome.Start(seed, Options, store, time);
}
}
- Options are shared by all tests. They freeze on the first
Start, and changing them afterwards throws. - Each
Startcreates a new session, so each test gets its own entities, sequences and clock. Parallel tests are safe.
2. A blueprint, and your first Add
A blueprint is a static readonly value with one factory per type. The factory gets a context (ctx) with the seed, the clock and ID generation.
[BiomeGraft]
public static class Blueprints
{
[Graft("CodeRepository", Plural = "CodeRepositories")]
public static readonly Blueprint<ZeeqSeed, CodeRepository> Repository =
Blueprint.For<ZeeqSeed, CodeRepository>(ctx => new()
{
Id = ctx.NewId("repo"), // "repo_<uuidv7>"
OrganizationId = ctx.Seed.Organization.Id,
OwnerQualifiedName = "zeeq-ai/example",
Enabled = true,
CreatedAtUtc = ctx.Now, // never DateTimeOffset.UtcNow
});
}
public sealed class CodeRepositoryTests
{
[Test]
public async Task Repository_belongs_to_seed_organization()
{
await TestBiome.Start(out var seed)
.AddCodeRepository(out var repo)
.BuildAsync();
await Assert.That(repo.OrganizationId).IsEqualTo(seed.Organization.Id);
}
}
| Shape | Creates | out |
|---|---|---|
AddOne(bp, out var x) |
1 default | T |
AddOne(bp, out var x, x => …) |
1 customized | T |
Add(bp, out var xs) |
1 default | T[] |
Add(bp, out var xs, 3) |
3 defaults | T[] |
Add(bp, out var xs, x => …, x => …) |
one per customizer | T[] |
- Adds are eager.
repoexists as soon asAddOnereturns, andBuildAsyncsaves everything once. Calling it a second time throws. - Every Add takes an
out. Writeout _when the test doesn't need the value.
[BiomeGraft] on the catalog generates the AddCodeRepository methods from its blueprint fields. Roots are available from any anchor; a child graft is available only from its declared parent array. [Graft("Name", Plural = "Names")] overrides the method suffixes, and [NoGraft] excludes a catalog member. See Generated grafts and scenario helpers.
3. Customize only what the test is about
The factory supplies the defaults, and the call site sets only the property being tested:
await TestBiome.Start(out _)
.AddCodeRepository(out var disabled, r => r.Enabled = false)
.AddCodeRepositories(out var pair,
r => r.OwnerQualifiedName = "zeeq-ai/api",
r => r.OwnerQualifiedName = "zeeq-ai/web")
.BuildAsync();
Traits are named Action<T> values, so you can pass them anywhere a customizer goes. A comma makes a new entity; + stacks traits on the same entity.
public static class Repositories
{
public static readonly Action<CodeRepository> Disabled = r => r.Enabled = false;
public static readonly Action<CodeRepository> Hidden = r => r.VisibleInLibraryPicker = false;
}
await TestBiome.Start(out _)
.Add(Blueprints.Repository, out var repos,
Repositories.Disabled, // repos[0]: disabled
Repositories.Disabled + Repositories.Hidden) // repos[1]: disabled and hidden
.BuildAsync();
Variants are new blueprint values. The original is unchanged.
public static partial class Blueprints
{
public static readonly Blueprint<ZeeqSeed, CodeRepository> DisabledRepository =
Repository.With(Repositories.Disabled);
}
Customizers that need the context take two parameters, (r, ctx) => r.CreatedAtUtc = ctx.Ago(TimeSpan.FromDays(30)). C# can't mix one- and two-parameter lambdas in a single call, so when you mix them, write all of them in the two-parameter form.
Derived defaults are fill-only. .Derive assigns a property only if it still has its default value after the traits and customizers have run. A value the test set is never overwritten.
public static partial class Blueprints
{
public static readonly Blueprint<ZeeqSeed, OrganizationMembership> PendingInvitation =
Blueprint.For<ZeeqSeed, OrganizationMembership>(ctx => new()
{
Id = ctx.NewId("mem"),
OrganizationId = ctx.Seed.Organization.Id,
Role = "member",
Status = MembershipStatus.Pending,
InvitedEmail = "invited@example.test",
CreatedByUserId = ctx.Seed.Owner.Id,
CreatedAtUtc = ctx.Now,
})
.Derive(m => m.ExpiresAtUtc, (m, _) => m.CreatedAtUtc.AddDays(7));
}
var created = new DateTimeOffset(2026, 1, 1, 0, 0, 0, TimeSpan.Zero);
await TestBiome.Start(out _)
.Add(Blueprints.PendingInvitation, out var invitations,
i => i.CreatedAtUtc = created, // ExpiresAtUtc derived → Jan 8
i => i.ExpiresAtUtc = created.AddDays(-1)) // explicit → kept
.BuildAsync();
"Unset" means default(TProp): null, 0, false, default(DateTimeOffset). An empty string "" counts as set. In the factory, leave derived properties unset.
Note: customizers assign properties, so any property a test varies on the entity needs a setter. With
{ get; init; }, the compiler rejects assignment from a customizer (CS8852).Derivecan still fillinitproperties. For rows that must stay immutable, customize an input instead; see Input blueprints.
4. Children: chain, branch, fan out
A child blueprint takes its parent as a typed argument. ctx.Ancestor<T>() reaches further up the chain.
public static partial class Blueprints
{
public static readonly ChildBlueprint<ZeeqSeed, CodeRepository, PullRequestRecord> PullRequest =
Blueprint.ChildOf<ZeeqSeed, CodeRepository, PullRequestRecord>((repo, ctx) => new()
{
Id = ctx.NewId("pr"),
RepositoryId = repo.Id,
CreatedAtUtc = ctx.Now,
});
[Graft("CodeReview", Plural = "CodeReviews")]
public static readonly ChildBlueprint<ZeeqSeed, PullRequestRecord, CodeReviewRecord> Review =
Blueprint.ChildOf<ZeeqSeed, PullRequestRecord, CodeReviewRecord>((pr, ctx) => new()
{
Id = ctx.NewId("review"),
OrganizationId = pr.OrganizationId,
PullRequestRecordId = pr.Id,
RepositoryId = ctx.Ancestor<CodeRepository>().Id, // grandparent
Status = CodeReviewStatus.Pending,
CreatedAtUtc = ctx.Now,
});
[Graft("ActiveLock", Plural = "ActiveLocks")]
public static readonly ChildBlueprint<ZeeqSeed, CodeReviewRecord, ActiveCodeReviewLock> ActiveLock =
Blueprint.ChildOf<ZeeqSeed, CodeReviewRecord, ActiveCodeReviewLock>((review, ctx) => new()
{
OrganizationId = review.OrganizationId,
RepositoryId = review.RepositoryId,
PullRequestRecordId = review.PullRequestRecordId,
CodeReviewRecordId = review.Id,
AcquiredAtUtc = ctx.Now,
ExpiresAtUtc = ctx.FromNow(TimeSpan.FromMinutes(5)),
});
}
(Blueprints.PullRequest, a child of CodeRepository, is shown in step 6.)
Every Add re-anchors to its own output, so a chain reads top to bottom:
await TestBiome.Start(out _)
.AddCodeRepository(out var repo)
.AddPullRequest(out var pr) // parent: repo
.AddCodeReview(out var review) // parent: pr
.AddActiveLock(out var activeLock) // parent: review
.BuildAsync();
- A child graft compiles only when the current anchor is its parent type. For example, adding a review directly after a repository is a compile error.
.Under(x)jumps back to an earlier Add output, or to a seed row listed inSeedEntities, so you can add siblings or start another branch. Objects the session didn't create are rejected.- Fan-out is per parent. Each customizer runs once for each parent, and the results come back as one flat array ordered by parent:
await TestBiome.Start(out _)
.AddCodeRepositories(out var repos, 2)
.AddPullRequests(out var prs, 3) // 3 per repo → 6: prs[0..2] → repos[0], prs[3..5] → repos[1]
.BuildAsync();
A singular child graft throws unless the anchor holds exactly one parent.
5. Generated grafts and scenario helpers
The generator emits three graft methods for each eligible member with valid names in a [BiomeGraft] catalog: one singular method with an optional Action<T> customizer, and two plural methods (count or params customizers). For example, a Repository field renamed with [Graft("CodeRepository", Plural = "CodeRepositories")] produces AddCodeRepository and AddCodeRepositories in BlueprintsGrafts.
[BiomeGraft]
public static class Blueprints
{
[Graft("CodeRepository", Plural = "CodeRepositories")]
public static readonly Blueprint<ZeeqSeed, CodeRepository> Repository = null!;
public static readonly ChildBlueprint<ZeeqSeed, CodeRepository, PullRequestRecord> PullRequest =
null!;
[Graft("CodeReview", Plural = "CodeReviews")]
public static readonly ChildBlueprint<ZeeqSeed, PullRequestRecord, CodeReviewRecord> Review =
null!;
[Graft("ActiveLock", Plural = "ActiveLocks")]
public static readonly ChildBlueprint<ZeeqSeed, CodeReviewRecord, ActiveCodeReviewLock> ActiveLock =
null!;
}
Root graft methods are generic in the anchor and work from any step with the same seed; their count or customizer list applies once at the current anchor. Child graft methods accept only the parent-array anchor, fan out for each parent, and do not create ancestors. Child counts and customizers apply to each parent; a plural call with no customizers creates one default output per parent (or one for a root). A root and child with colliding method signatures on the same builder produce BIOME001.
Use the core Add / AddOne API with a blueprint for a one-off context-aware customizer. Handwrite an AddX extension only for a repeated context-aware overload or another app-specific exception. Keep named multi-step chains as scenario helpers:
public static class CodeReviewScenarioHelpers
{
extension(BiomeBuilder<ZeeqSeed, ZeeqSeed> b)
{
public BiomeBuilder<ZeeqSeed, ActiveCodeReviewLock[]> AddLockedReview(
out ActiveCodeReviewLock activeLock) =>
b.AddCodeRepository(out _)
.AddPullRequest(out _)
.AddCodeReview(out _)
.AddActiveLock(out activeLock);
}
}
A lock test now names only the lock:
await TestBiome.Start(_db, out _).AddLockedReview(out var activeLock).BuildAsync();
In zeeq-app, the same setup took a five-step EntityGraph chain, a five-slot tuple discard and activeLocks[0]. Each scenario helper's whole chain is visible in its definition. Biome has no hidden auto-creation of ancestors.
6. Sequences for numbers and ordering
A sequence is a value you declare once: a start value and a next function. Its state lives in each session, so it continues across Add calls and never leaks between tests. Assign it with fill-only Derive:
public static partial class Blueprints
{
public static readonly Sequence<int> PullRequestNumbers =
Sequence.Of(1, n => n + 1).Per<CodeRepository>(); // numbering restarts per repository
public static readonly ChildBlueprint<ZeeqSeed, CodeRepository, PullRequestRecord> PullRequest =
Blueprint.ChildOf<ZeeqSeed, CodeRepository, PullRequestRecord>((repo, ctx) => new()
{
Id = ctx.NewId("pr"),
OrganizationId = repo.OrganizationId,
RepositoryId = repo.Id,
State = PullRequestState.Open,
CreatedAtUtc = ctx.Now,
})
.Derive(pr => pr.PullRequestNumber, PullRequestNumbers)
.Derive(pr => pr.Title, (pr, _) => $"Pull request {pr.PullRequestNumber}") // runs after the number
.Derive(pr => pr.Branch, (pr, _) => $"feature/{pr.PullRequestNumber}")
.Derive(pr => pr.GitHubNodeId, (_, ctx) => ctx.NewId("PR")); // globally unique
}
| Scope | One counter per | Example |
|---|---|---|
.PerSession() (default) |
test | anything unique across the whole test |
.PerParent() |
immediate parent | card rank within a column |
.Per<TAncestor>() |
nearest ancestor of that type | PR number within a repository |
await TestBiome.Start(out _)
.AddCodeRepositories(out var repos, 2)
.AddPullRequests(out var first, 2) // 1, 2 | 1, 2
.Under(repos[0])
.AddPullRequest(out var third) // 3: resumes repos[0]
.Under(repos[0])
.AddPullRequests(out var mixed,
_ => { }, // 5 (4 is taken below)
pr => pr.PullRequestNumber = 4) // explicit: kept, reserved
.BuildAsync();
- Explicit values win. They don't move the sequence, and the sequence skips any value already set explicitly in that scope, whether in this step or an earlier one. It can't know about values set in a later step, so set explicit values in or before the step that fills.
Derives run in declaration order. Derive values likeTitleafter the number they depend on.- Variants made with
.Withshare their base blueprint's sequence. - For globally unique values, use a
PerSessionsequence orctx.NewId. - In a static class, fields initialize top to bottom. Declare a sequence above the blueprints that use it, and keep a base blueprint and its variants in the same file. Across
partialfiles, the initialization order is unspecified.
7. Time
- The session has one clock. In blueprints,
ctx.Now,ctx.Ago(…)andctx.FromNow(…)are the only time sources. - If you pass no clock, time is frozen when
Startis called. Every entity in the test sees the sameNow. - If you pass a
TimeProvider, it is read live. Share the sameFakeTimeProviderwith the system under test and advance it during the test:
var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 11, 12, 0, 0, TimeSpan.Zero));
await TestBiome.Start(out _, time: time)
.AddLockedReview(out var activeLock) // ExpiresAtUtc = 12:05
.BuildAsync();
time.Advance(TimeSpan.FromMinutes(6)); // the SUT reads the same clock: the lock has expired
- Values are rounded to 1 µs by default, whichever clock you use. That matches Postgres
timestamptz, so values you read back compare equal to the ones you wrote. Change it withoptions.TimePrecision(TimeSpan.FromMilliseconds(1)), or turn it off withTimeSpan.Zero.
8. Save: EF Core, or no database
BuildAsync passes the rows to an IBiomeStore. The seed's rows come first, then created entities in creation order.
using Microsoft.EntityFrameworkCore;
using Zeeq.Biome.EntityFrameworkCore;
public static partial class TestBiome
{
public static BiomeBuilder<ZeeqSeed, ZeeqSeed> Start(
DbContext db, out ZeeqSeed seed, TimeProvider? time = null) =>
Start(out seed, new EfCoreStore(db), time);
}
await TestBiome.Start(_db, out var seed)
.AddLockedReview(out var activeLock)
.BuildAsync(); // AddRange in order, one SaveChangesAsync
_db.ChangeTracker.Clear(); // so the code under test reads rows from the database, not tracked instances
db.AsBiomeStore()is shorthand fornew EfCoreStore(db).- No database: omit the store.
NullStoresaves nothing, and entities still exist as soon as each Add returns. - Database-generated keys: set navigation properties in child factories, for example
Repository = repo, and EF fills in the foreign keys. - Anything else: implement
IBiomeStore, a singleSaveAsync(IReadOnlyList<object>, CancellationToken)method.
Use cases and scenarios
These examples use the generated grafts from step 5, including AddUser / AddUsers, and these traits:
public static class PullRequests
{
public static readonly Action<PullRequestRecord> Closed = pr => pr.State = PullRequestState.Closed;
public static readonly Action<PullRequestRecord> Merged = pr => pr.State = PullRequestState.Merged;
public static readonly Action<PullRequestRecord> Draft = pr => pr.IsDraft = true;
}
Linear chains: name what you assert, discard the rest
await TestBiome.Start(_db, out _)
.AddCodeRepository(out _)
.AddPullRequest(out _, PullRequests.Merged)
.AddCodeReview(out var review, r => r.Status = CodeReviewStatus.Running)
.BuildAsync();
Siblings and branches with .Under
await TestBiome.Start(out _)
.AddCodeRepository(out var repo)
.AddPullRequest(out var open)
.Under(repo) // back to the repository
.AddPullRequest(out var closed, PullRequests.Closed)
.AddCodeReview(out var review) // reviews `closed`
.BuildAsync();
Fan-out per parent, with parent-aware customizers
Child customizers in the two-parameter form get ctx.Parent, ctx.ParentIndex, and ctx.Index (the position under that parent):
public static partial class Blueprints
{
public static readonly ChildBlueprint<ZeeqSeed, User, UserAlias> UserAlias =
Blueprint.ChildOf<ZeeqSeed, User, UserAlias>((user, ctx) => new()
{
Id = ctx.NewId("alias"),
OrganizationId = ctx.Seed.Organization.Id,
UserId = user.Id,
Kind = UserAliasKind.GitHub,
DisplayValue = $"{user.Id}@example.test",
})
.Derive(a => a.NormalizedValue, (a, _) => a.DisplayValue.Trim().TrimStart('@').ToLowerInvariant());
}
await TestBiome.Start(out _)
.AddUsers(out var users, u => u.DisplayName = "Ada", u => u.DisplayName = "Grace")
.Add(Blueprints.UserAlias, out var aliases,
(alias, ctx) => alias.DisplayValue = $"@{ctx.Parent.DisplayName}")
.BuildAsync();
// aliases: "@Ada" → "ada", "@Grace" → "grace"; two customizers would give 4 aliases (2 per user)
Transient "input" entities
Sometimes the code under test should create a row, and the test only needs to build the input. .Transient() excludes the most recent Add step from the save, but the entities are still returned:
await TestBiome.Start(_db, out _)
.AddOne(Blueprints.PendingInvitation, out var invitation, i => i.InvitedEmail = "new@example.test")
.Transient() // put it right after the step it marks
.BuildAsync();
// act: await invitations.CreateAsync(invitation);
To make a blueprint transient everywhere it's used, declare it with Blueprints.PendingInvitation.AsTransient().
With EF Core, a transient entity that a saved row references through a navigation property is still saved: EF follows the reference. Keep transient inputs unreferenced.
Ordering with sequences
public static partial class Blueprints
{
public static readonly Sequence<int> FindingPositions = Sequence.Of(1, n => n + 1).PerParent();
public static readonly ChildBlueprint<ZeeqSeed, CodeReviewRecord, ReviewFinding> Finding =
Blueprint.ChildOf<ZeeqSeed, CodeReviewRecord, ReviewFinding>((review, ctx) => new()
{
Id = ctx.NewId("finding"),
CodeReviewRecordId = review.Id,
})
.Derive(f => f.Position, FindingPositions);
}
Positions continue per review across Add calls. .Under takes an entity you captured with out:
await TestBiome.Start(out _)
.AddCodeRepository(out _)
.AddPullRequest(out _)
.AddCodeReview(out var review)
.Add(Blueprints.Finding, out var findings, 3) // positions 1, 2, 3
.Under(review)
.Add(Blueprints.Finding, out var later) // position 4: resumes this review
.BuildAsync();
Time-sensitive tests sharing a clock with the SUT
var time = new FakeTimeProvider(new DateTimeOffset(2026, 10, 11, 12, 0, 0, TimeSpan.Zero));
await TestBiome.Start(_db, out _, time)
.AddCodeRepository(out _)
.AddPullRequest(out _)
.AddOne(Blueprints.Review, out var review,
(r, ctx) => r.CreatedAtUtc = ctx.Ago(TimeSpan.FromMinutes(4)))
.BuildAsync();
// act: var sweeper = new StaleReviewSweeper(_db, time); time.Advance(…); await sweeper.RunAsync();
Generated graft customizers take Action<T> (or Action<TInput> for an input blueprint). For a one-off context-aware customizer, call core AddOne / Add with the blueprint directly. If tests repeatedly need the clock or parent for a type, add only a named handwritten context overload for that type.
Unit tests with no database
TestBiome.Start(out _) // NullStore; no BuildAsync needed
.AddCodeRepository(out var repo)
.AddPullRequests(out var prs, PullRequests.Draft, PullRequests.Merged);
// act: var summary = PullRequestSummary.From(repo, prs);
Call BuildAsync only when you need a save or save hooks.
Custom seed shapes
The seed can be anything: a record, a string, an existing fixture object.
public sealed record TenantSeed(string Actor, int Tenant);
var options = new BiomeOptions<TenantSeed>(); // usually a static readonly field
var biome = Biome.Start(new TenantSeed("actor", 42), options);
Global defaults and save hooks
Global defaults fill unset properties on any entity that matches a base class or interface. They run after the blueprint's own Derives, and like them they never overwrite. Save hooks see the final row list and may add or remove rows.
private static readonly BiomeOptions<ZeeqSeed> Options = new BiomeOptions<ZeeqSeed>()
.SeedEntities(seed => [seed.Owner, seed.Organization])
.Default<IUpdatedEntity, DateTimeOffset>(e => e.UpdatedAtUtc, (_, ctx) => ctx.Now)
.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;
})
.AfterSave((save, _) => ValueTask.CompletedTask); // e.g. logging, assertions on what was saved
Use hooks sparingly. Rows a test can't see make it harder to read. Rows added in BeforeSave skip the per-entity pipeline: no traits, Derives, or global defaults are applied to them.
Input blueprints: customize an input, then build the row
Package API: ordinary entity outputs from
FromInputandChildFromInputrequire a referenced package that exposes those forms. Check the package API before using them, and align the EF Core package version. This README does not identify a package release for these APIs.
A customizer runs after the factory, so it cannot set an init-only property. (A required member with a setter can still be customized.) An input blueprint lets tests customize a small settable input, then the factory reads it to build the row, so production models stay as they are. If PullRequestRecord.PullRequestNumber were init, as in zeeq-app's model, it would look like this:
public sealed class PullRequestInput
{
public int Number { get; set; }
public string? Title { get; set; }
}
public static readonly ChildInputBlueprint<ZeeqSeed, CodeRepository, PullRequestInput, PullRequestRecord> PullRequestFromInput =
Blueprint
.ChildFromInput<ZeeqSeed, CodeRepository, PullRequestInput, PullRequestRecord>(
() => new(),
(repo, input, ctx) => new()
{
Id = ctx.NewId("pr"),
OrganizationId = repo.OrganizationId,
RepositoryId = repo.Id,
PullRequestNumber = input.Number, // init-only: set during construction
Title = input.Title!,
State = PullRequestState.Open,
CreatedAtUtc = ctx.Now,
})
.Derive(i => i.Number, PullRequestNumbers) // fills the input; explicit numbers are reserved
.Derive(i => i.Title, (i, _) => $"Pull request {i.Number}");
await TestBiome.Start(out _)
.AddCodeRepository(out var repo)
.Add(Blueprints.PullRequestFromInput, out var prs, 2) // numbers 1, 2
.Under(repo)
.AddOne(Blueprints.PullRequestFromInput, out var pr10, i => i.Number = 10) // explicit number
.BuildAsync();
Blueprint.FromInputbuilds a root.Blueprint.ChildFromInputbuilds a child and passes the typed parent to the factory;ctx.Ancestor<T>()and per-parent fan-out work as forChildOf.- Traits, customizers and
Derives act on the input.outvariables and the new anchor hold the output. - An input is construction data: it is not recorded or saved, can't be a parent, and
All<PullRequestInput>()is empty. A root input blueprint stays a root after.Under(…). Derivetreats a property's default value (null,0,false) as unset. A nullable value type (int?) keeps an explicit0/falsebut not an explicitnull. Global defaults can also replacenull,0orfalseon the output.
See the Input blueprints guide for the order of work in a step and the aggregate form.
Aggregates: one input, several rows
When one concept is several rows, as with zeeq-app's OrganizationGraph, customize a small input object and build the rows from it. The aggregate implements IBiomeAggregate, and its parts are saved in place of the aggregate itself.
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"));
}
await TestBiome.Start(_db, out _)
.Add(Blueprints.Organization, out var orgs,
o => o.IsDefaultMembership = true,
o => o.Slug = "beta")
.BuildAsync(); // saves org, root team, membership for each, in GetEntities() order
Global defaults apply to the aggregate object itself, not to its parts. Parts aren't tracked as parents, so to hang children off an aggregate, make the aggregate type (OrganizationGraph) the child blueprint's parent.
Cheat sheet
| You want | Write |
|---|---|
| Start a test | TestBiome.Start(out var seed) / TestBiome.Start(_db, out var seed, time) |
| Call without naming a blueprint | [BiomeGraft] catalog generates {Catalog}Grafts; use [Graft] for names and [NoGraft] to skip a member |
| Root blueprint | Blueprint.For<TSeed, T>(ctx => new() { … }) |
| Child blueprint | Blueprint.ChildOf<TSeed, TParent, T>((parent, ctx) => new() { … }) |
| Root built from an input | Blueprint.FromInput<TSeed, TInput, T>(() => new(), (input, ctx) => …) |
| Child built from an input | Blueprint.ChildFromInput<TSeed, TParent, TInput, T>(() => new(), (parent, input, ctx) => …) |
| Variant | bp.With(trait) / bp.With((e, ctx) => …) |
| Derived default (fill-only) | bp.Derive(e => e.Prop, (e, ctx) => …) |
| Sequence | Sequence.Of(1, n => n + 1) + .PerSession() / .PerParent() / .Per<T>(); bp.Derive(e => e.Prop, seq) |
| Never save | bp.AsTransient() (blueprint) / .Transient() (most recent step) |
| One / many | AddOne(bp, out var x[, c]) / Add(bp, out var xs[, n \| c1, c2…]) |
| Jump back | .Under(entity) / .Under(array) |
| Context | ctx.Seed, ctx.Now, ctx.Ago(t), ctx.FromNow(t), ctx.NewId("p"), ctx.Index, ctx.Next(seq), ctx.Ancestor<T>(), ctx.All<T>(), ctx.Last<T>() |
| Child context | + ctx.Parent, ctx.ParentIndex |
| Inspect | biome.All<T>(), biome.Last<T>() (keep var biome = TestBiome.Start(…)) |
| Options | SeedEntities, Default<TBase, TProp>, BeforeSave, AfterSave, TimePrecision, Ids |
| Save | await biome.BuildAsync(ct): once; stores: NullStore, EfCoreStore, your IBiomeStore |
The one rule
The call site wins, then the more specific default. Nothing overwrites.
For each entity in an Add step:
- Factory creates it. IDs are set here with
ctx.NewId. - Traits (
.With) run, in declaration order. - Call-site customizers run.
Derives run, fill-only and in declaration order. Sequences first reserve every explicit value in the step, then fill the rest.- Global defaults (
options.Default) run, fill-only. - The entity is recorded, along with its parent link and step.
For an input blueprint, every input first gets its traits and customizers (steps 2 and 3); then, per input, its Derives (step 4) and the factory run in order; steps 5 and 6 apply to each output.
BuildAsync then:
- Takes the seed rows, followed by created entities in creation order.
- Expands aggregates.
- Drops transients and removes duplicate references.
- Runs
BeforeSave. - Calls
IBiomeStore.SaveAsync. - Runs
AfterSave, if the save succeeded.
Versioning and contributing
- Semantic Versioning, computed from Conventional Commits:
feat:→ minor,fix:→ patch,feat!:/BREAKING CHANGE:→ major. - Releases are cut by the Release workflow (GitHub Actions → stable or rc). It publishes both packages to nuget.org and tags
vX.Y.Z. - Build and test:
dotnet build zeeq-biome.slnxthendotnet test --solution zeeq-biome.slnx. Format:dotnet csharpier format . - Agent skills for writing tests and blueprints:
.agents/skills. - License: MIT.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Zeeq.Biome:
| Package | Downloads |
|---|---|
|
Zeeq.Biome.EntityFrameworkCore
Entity Framework Core store for Zeeq.Biome: persist a biome's entity graph with a DbContext in one SaveChanges. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.1.0 | 82 | 10/12/2026 |
| 1.1.0-rc.2 | 48 | 10/12/2026 |
| 1.1.0-rc.1 | 53 | 10/12/2026 |
| 1.0.1 | 86 | 10/12/2026 |
| 1.0.0 | 81 | 10/12/2026 |