ModelBuilder 8.7.0-beta.8

This is a prerelease version of ModelBuilder.
dotnet add package ModelBuilder --version 8.7.0-beta.8
                    
NuGet\Install-Package ModelBuilder -Version 8.7.0-beta.8
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="ModelBuilder" Version="8.7.0-beta.8" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="ModelBuilder" Version="8.7.0-beta.8" />
                    
Directory.Packages.props
<PackageReference Include="ModelBuilder" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add ModelBuilder --version 8.7.0-beta.8
                    
#r "nuget: ModelBuilder, 8.7.0-beta.8"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package ModelBuilder@8.7.0-beta.8
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=ModelBuilder&version=8.7.0-beta.8&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=ModelBuilder&version=8.7.0-beta.8&prerelease
                    
Install as a Cake Tool

ModelBuilder

A library for easy generation of model classes

GitHub license Nuget Nuget

Actions Status Coverage Status

ModelBuilder fills your classes, structs and primitives with realistic pseudo-random data so your tests can focus on behaviour instead of hand-built fixtures. A single call builds a whole object graph — nested types, constructor arguments, collections, enums and nullable values included.

var person = Model.Create<Person>();

The current version is a source-generated, reflection-free rewrite. Every type you build is discovered at compile time and gets a dedicated builder, so ModelBuilder:

  • runs under Native AOT and full trimming with no IL warnings,
  • reports an unbuildable type at build time (a diagnostic) instead of throwing at runtime,
  • is dramatically faster with far fewer allocations than the previous reflection engine (typically 2–14× faster and 6–50× fewer allocations — see ModelBuilder.BenchmarkTests/BASELINE.md).

This is a new major version with a deliberately smaller surface than the v8 reflection engine. If you are coming from v8, read the migration guide. Consumers that need runtime reflection, dynamic/runtime-only types, or the old extensibility pipeline should stay on the v8 line.

Creating a model

The static Model class is the entry point. It builds classes, structs and primitive types, and walks the whole object graph reachable from the type you ask for.

var person = Model.Create<Person>();

Value-source types such as enums and primitives can be built as roots directly:

var status = Model.Create<OrderStatus>();
var id = Model.Create<Guid>();

You can also build by runtime Type when the type is only known at runtime:

object person = Model.Create(typeof(Person));

This path still depends on compile-time discovery. A builder only exists for a type the generator saw at compile time, so the runtime Type you pass must already be buildable through one of the discovery triggers — a Model.Create<T>(), Construct<T>(), Populate<T>() or Model.Create(typeof(T)) call somewhere in the build, a Mapping<,>, or a [GenerateModelBuilder] annotation. Passing a Type that the generator never saw throws a ModelBuildException at runtime, because there is no generated builder to dispatch to:

// Throws at runtime: the concrete type came from reflection/config/a plugin, so the generator
// never saw it and produced no builder.
Type runtimeType = Type.GetType("MyApp.PluginPayload")!;
object instance = Model.Create(runtimeType);

To make such a type buildable, give the generator a compile-time anchor — most simply by annotating the type (or naming it from your test assembly):

[assembly: GenerateModelBuilder(typeof(MyApp.PluginPayload))]

Constructor arguments and parameter matching

Model.Create<T>() chooses a constructor and fills its arguments with generated values automatically — you do not pass constructor arguments to Create. Constructor selection prefers a parameterless constructor, then the constructor with the fewest parameters.

When a type is built through a constructor, ModelBuilder matches constructor parameters to properties of the same name (case-insensitive) and type at compile time, and the matched members are simply left out of the generated population code. A value the constructor assigned is therefore never overwritten — there is no runtime value comparison.

By default a value is generated for every parameter of the chosen constructor, including optional ones — an optional parameter's declared default is not special-cased, so that member is still varied like any other. Set BuildOptions.UseConstructorDefaults (see Tuning the build) to instead use an optional parameter's declared default value. For per-argument control, use typed construction, whose From overloads expose each optional parameter's default.

Constructor arguments also become sibling values for the rest of the build, keyed by the parameter name, so members derived from siblings stay consistent with them. This holds even when a parameter is never exposed as a property — a type with Contact(string firstName, string lastName) and only an Email property still gets an Email built from those constructor arguments, because firstName/lastName are recorded as siblings (matched case-insensitively against the FirstName/LastName the email source looks for).

To supply specific constructor arguments yourself, use typed construction.

Typed construction

Model.Create<T>() always generates the constructor arguments for you. When you want to pass specific constructor arguments, use Model.Construct<T>().From(...):

var person = Model.Construct<Person>().From("Fred", "Smith");

The generator emits a From overload for every public constructor of T, so the editor offers exactly that type's constructors — with the real parameter names and the constructor's own documentation copied across. Because the arguments are strongly typed:

  • value-type arguments are not boxed and there is no object?[] array,
  • a wrong argument type or count is a compile-time error, not a runtime exception,
  • members assigned by the chosen constructor are not re-populated with random values (they keep the values you passed, and sibling-derived members such as Email stay consistent with them).

Constructor parameters that declare a default value are emitted as optional on the generated From overload, so you can omit them to take the default or pass an argument to override it:

// Connection(string host, int port = 8080, bool secure = true)
var fromDefaults = Model.Construct<Connection>().From("db1");        // port 8080, secure true
var overridden = Model.Construct<Connection>().From("db1", 9090);   // port 9090, secure true

Every other member is still populated as usual, and the whole configuration chain composes with it:

var order = Model.Ignoring<Order>(x => x.InternalId)
    .Construct<Order>()
    .From(customerId);

The From overloads are generated as extension methods in the ModelBuilder namespace. Because you already have using ModelBuilder; in scope wherever you write Model.Construct<T>(), they are available with no extra using, and they appear in the editor as soon as Model.Construct<T>() is written.

Ignoring members

Ignoring a member skips it during population, leaving it at whatever value the constructor or the type's own initializer set. There are three ways to declare an ignore rule, depending on whether you target one member, one member on one type, or any member matching a condition.

Model.Ignoring<T>(expression) is the fluent per-build form. It selects a member with a strongly typed expression and returns a configuration you continue building from:

var person = Model.Ignoring<Person>(x => x.FirstName).Create<Person>();

The examples here finish with Create<T>(), but every fluent entry point on Model returns the same configuration, and you can complete it with whichever build you need — Create<T>() for a new instance, Populate<T>(instance) to fill one you already have, or Construct<T>().From(...) to supply constructor arguments. The configuration also keeps composing, so you can chain further Ignoring, Mapping, AddValueSource, UsingModule or WriteLog calls before the terminal build.

The rule applies wherever that member appears in the graph, so it also covers types nested deeper than the root:

var person = Model.Ignoring<Address>(x => x.AddressLine1).Create<Person>();

The remaining two forms are also fluent on Model (and on the configuration a module receives), so they compose in the same chain as Ignoring<T>.

Ignoring(Type declaringType, string memberName) targets a single member on a specific type by name — the non-generic equivalent of Ignoring<T>, useful when you only have the Type or want to name the member as a string:

var request = Model.Ignoring(typeof(Request), nameof(Request.Data)).Create<Request>();

The type is matched by assignability, so a rule declared on a base class or an interface also applies to the same-named member on any derived or implementing type. For example, Ignoring(typeof(Stream), nameof(Stream.CanRead)) ignores CanRead wherever a MemoryStream (or any other Stream) appears in the graph, and a rule on an interface covers every type that implements it. Declare the rule on the exact type to scope it to that type alone.

IgnoringAny(Func<MemberSignature, bool> predicate) ignores every member, on any type, for which the predicate returns true. The predicate receives a MemberSignature exposing the member's DeclaringType, Name and MemberType, so you can match by naming convention, declaring type or member type across the whole graph:

// Ignore every member whose name ends in "Internal", regardless of which type declares it.
var account = Model.IgnoringAny(member => member.Name.EndsWith("Internal", StringComparison.Ordinal))
    .Create<Account>();

// Ignore every Stream-typed member anywhere in the graph.
var report = Model.IgnoringAny(member => typeof(Stream).IsAssignableFrom(member.MemberType))
    .Create<Report>();

Inside a configuration module the same two rules are declared on the IBuildConfiguration as Ignore(Type, memberName) and IgnoreAny(predicate) (imperative names, identical behaviour), so they can be packaged and reused.

All three forms compose — a build sees the union of every ignore rule from the fluent chain and from each applied module.

Mapping abstract and interface types

Abstract and interface members need a concrete type to build. Declare a mapping and that concrete type is used wherever the source type appears:

var payload = Model.Mapping<IShipment, GroundShipment>().Create<Parcel>();

There is no automatic assembly scan that guesses a concrete type — the mapping is explicit, which is what keeps the build deterministic and reflection-free.

Custom value sources

When the built-in data does not produce what your model needs, register a custom value source. A value source is an IValueSource<T> that produces a T for a build target (a constructor parameter or settable member) or a root; the simplest way to supply one is DelegateValueSource<T> wrapping a lambda. A registered source takes precedence over the built-in sources.

Register a source for every value of a type to control all build targets of that type. The following code generates every OrderStatus value in the graph as OrderStatus.Pending:

var order = Model.AddValueSource(new DelegateValueSource<OrderStatus>(c => OrderStatus.Pending))
    .Create<Order>();

Register a source scoped to specific member names to narrow it to the constructor parameters or settable members whose name matches one of the supplied member names (matched as a whole PascalCase/camelCase word, the same way the built-in entity data is matched). The source still only applies to build targets of its T, so it matches on both the member name and the type. A member-name-scoped source takes precedence over a type-only source for a matching build target. The following code uses this expression for every string parameter or property named AccountNumber:

var account = Model.AddValueSource(
        new DelegateValueSource<string>(c => "acct-" + c.Random.NextInt32(1000, 9999)),
        "AccountNumber")
    .Create<Account>();

The factory receives the IBuildContext, so a custom source can use the same seams the built-in sources use — the random source, GetSibling<T> to stay consistent with other members already set on the instance, and GetOrAddScopedValue<T> to share one cached data item across several related members (the mechanism the built-in location and date-of-birth sources use). In the following module AddressRow is your own type and data set (not part of ModelBuilder); the module draws a Street/AddressLine1 and City from a single cached AddressRow so they stay consistent on each instance:

public sealed class AddressModule : IConfigurationModule
{
    private const string AddressKey = "MyApp.Address";

    public void Configure(IBuildConfiguration configuration)
    {
        // Both members draw from one cached address row, so they stay internally consistent.
        configuration.AddValueSource(
            new DelegateValueSource<string>(c => RowFor(c).Street),
            "Street", "AddressLine1");
        configuration.AddValueSource(
            new DelegateValueSource<string>(c => RowFor(c).City),
            "City");
    }

    // AddressRow is your own type holding a related set of address fields. ModelBuilder does not
    // supply it; this example assumes an AddressRow.Random() factory you provide.
    private static AddressRow RowFor(IBuildContext context)
    {
        return context.GetOrAddScopedValue(AddressKey, _ => AddressRow.Random());
    }
}

Custom value sources can be registered through a configuration module's IBuildConfiguration exactly as above, so they can be packaged and shared like mappings and ignore rules.

Writing a reusable value source

DelegateValueSource<T> is the quickest way to supply a source, and it has two factory shapes. The first ignores where the value is going; the second also receives a BuildTarget describing the type and (optional) member name being built, so one source can vary its output by member:

// Same value everywhere.
var pending = new DelegateValueSource<OrderStatus>(context => OrderStatus.Pending);

// Vary the value by the member being built.
var labelled = new DelegateValueSource<string>((context, target) =>
    target.MemberName is null ? "value" : target.MemberName + "-" + context.Random.NextInt32(1, 999));

A lambda is ideal for a one-off source defined right where it is registered. When the source is worth keeping as a reusable, testable unit, however, implement IValueSource<T> as a dedicated class. Reach for a class when the source holds its own state or dependencies, when several tests or configuration modules need to share the exact same logic, or simply when a descriptive class name documents intent better than an inline lambda. A class also gives the source a single place to unit-test.

The source's Create method receives the same IBuildContext and BuildTarget that DelegateValueSource<T> passes to its factory:

public sealed class SkuValueSource : IValueSource<string>
{
    public string Create(IBuildContext context, in BuildTarget target)
    {
        return "SKU-" + context.Random.NextInt32(10000, 99999);
    }
}

// "Sku" is the member name to match (as in the AccountNumber example above); it is unrelated to the
// SkuValueSource class name. Drop it to apply the source to every string build target instead.
var product = Model.AddValueSource(new SkuValueSource(), "Sku").Create<Product>();

NullableValueSource<T> wraps a value-type source so it produces T?. It returns null for a share of values set by BuildOptions.NullPercentage (a 5% default; see Tuning the build) and otherwise delegates to the underlying source — the mechanism that makes generated nullable members occasionally null.

The build context seams

The IBuildContext passed to every value source exposes the same seams the engine and built-in sources use, so a custom source can participate fully in a build:

Member Purpose
Random The build's IRandomSource — seedable, thread-safe, typed (see Generating random values).
GetSibling<T>(name) / GetSibling<T>(names...) Read a value already set on the instance being built (the first match across the candidate names), so derived members stay consistent. Returns default when there is no match.
GetOrAddScopedValue<T>(key, factory) Get-or-create a value cached for the lifetime of the current instance, so several sources can share one data item (such as an address row). A different instance elsewhere in the graph gets its own scope.
NextCount() A random collection length within the configured min/max bounds (a 1–10 default; tune with SetOptions).
Build<T>(declaringType, name) Recursively build a nested member value through the normal source/builder resolution, with circular-reference and depth guards applied.
ShouldPopulate(declaringType, name, memberType) Whether a member passes the configured ignore rules.
RecordSibling(name, value) / EnterSiblingScope() Record a value into, or open, the sibling scope that GetSibling<T> reads.
Configuration / Log The active IBuildConfiguration and IBuildLog for the build.

All numeric range methods on Random are inclusive of both bounds, and GetSibling<T> name matching is the same whole-word, case-insensitive scheme used by the built-in entity data.

Runnable examples

A complete, runnable ModelBuilder.Examples console project demonstrates every scenario above end to end. Its source is bundled in this NuGet package (under ModelBuilder.Examples/, mirroring the repository), so the links below resolve both on GitHub and when browsing the package contents. The project references the generator as an analyzer — the wiring a consuming project needs for the source generator to run and emit the From overloads:

<ProjectReference Include="..\ModelBuilder.Generator\ModelBuilder.Generator.csproj"
                  OutputItemType="Analyzer"
                  ReferenceOutputAssembly="false" />

Run the whole set with dotnet run --project ModelBuilder.Examples. Each scenario lives in its own file under ModelBuilder.Examples/Examples, building the sample model types in ModelBuilder.Examples/Models:

Scenario Example file
Creating a model CreatingModelsExample.cs
Typed construction TypedConstructionExample.cs
Ignoring members IgnoringMembersExample.cs
Mapping abstract and interface types MappingTypesExample.cs
Custom value sources CustomValueSourcesExample.cs, SkuValueSource.cs
Populating a model PopulatingModelsExample.cs
Changing the model after creation ChangingAfterCreationExample.cs
Logging the build process LoggingBuildsExample.cs
Handling build failures HandlingFailuresExample.cs
Configuration modules ConfigurationModulesExample.cs, ExampleModule.cs
Tuning the build TuningTheBuildExample.cs
Built-in data BuiltInDataExample.cs

How types are discovered

Every buildable type is found at compile time by a source generator. A type gets a generated builder when it is reachable from one of these triggers:

  1. Model.Create<T>(), Model.Populate<T>(), Model.Construct<T>() or Model.Create(typeof(T))T and everything reachable from it (constructor parameters and public settable properties) become buildable. This covers almost everything with no annotations.
  2. Model.Mapping<TSource, TTarget>() — makes TTarget buildable for abstract/interface members.
  3. [GenerateModelBuilder] — names a root that is only ever built polymorphically or via a runtime Type.

Only compile-time-discoverable types are buildable. private/protected types, constructors and members cannot be built; internal types are buildable only when exposed to the building assembly with [InternalsVisibleTo].

Build-time diagnostics

If a type you try to build is not reachable from one of the triggers above, you get a compiler diagnostic, not a runtime exception:

Diagnostic Meaning
MB1001 The requested root type cannot have a builder generated (for example it is abstract, an interface, generic or has no accessible constructor).
MB1002 The root type is inaccessible (for example private) to the generated code.
MB1005 A Model.Create(typeof(X)) root could not be resolved to a buildable type.

GenerateModelBuilder

Use [GenerateModelBuilder] to opt in a root that is never named directly in a Model.Create<T>() call — for example a type you only ever build through a base type or a runtime Type.

[GenerateModelBuilder]
internal sealed class AuditRecord
{
    public Guid Id { get; set; }

    public DateTimeOffset At { get; set; }
}

To opt a production type into generation without shipping the attribute in the production binary, use the assembly-level form from your test assembly:

[assembly: GenerateModelBuilder(typeof(MyApp.AuditRecord))]

Populating a model

When you already have an instance, ModelBuilder can fill it in place:

var person = new Person
{
    FirstName = "Jane"
};

// Fill every other member but keep the FirstName already set.
Model.Ignoring<Person>(x => x.FirstName).Populate(person);

var customer = Model.Populate(new Person());

Changing the model after creation

Set tweaks an instance after it is built, and SetEach does the same across a sequence. Both are plain extension methods, so they work on any object regardless of how it was created.

var person = Model.Create<Person>()
    .Set(x => x.FirstName = "Joe")
    .Set(x => x.Email = null);

var other = Model.Create<Person>().Set(x =>
{
    x.FirstName = "Joe";
    x.Email = null;
});

var organisation = Model.Create<Organisation>();

organisation.Staff.SetEach(x => x.Email = null);

SetEach has overloads for the common collection and dictionary shapes (IEnumerable<T>, IList<T>, ICollection<T>, IReadOnlyList<T>, List<T>, Collection<T>, ReadOnlyCollection<T>, the IDictionary/IReadOnlyDictionary/Dictionary/ReadOnlyDictionary families) and returns the same collection type it received, so it stays chainable. A second overload passes the index alongside each item:

var staff = Model.Create<Organisation>().Staff;

// The lambda receives the zero-based index and the item.
staff.SetEach((index, person) => person.EmployeeNumber = index + 1);

The SetEach overloads above cover the common collection and dictionary types by name. If your variable's type is some other collection — a Queue<T>, a HashSet<T>, or a custom collection — none of those overloads match it, so the compiler cannot pick one. SetEachExplicit is the fallback for that case: it works on any IEnumerable<TEntry> (or IEnumerable<KeyValuePair<TKey, TValue>> for dictionaries), runs the action over every item, and returns the collection unchanged. Because the compiler cannot infer the type arguments from the receiver alone, you supply them explicitly — the collection type first, then the item type — which is what gives the method its name. Index-aware overloads are available too, matching SetEach:

var queue = new Queue<Person>(Model.Create<List<Person>>());

// <Queue<Person>, Person> = <the collection type, the item type>.
queue.SetEachExplicit<Queue<Person>, Person>((index, person) => person.Email = null);

Logging the build process

ModelBuilder can render a structured log of how a value was built. WriteLog enables logging and sends the rendered log to your sink after the build completes. With xUnit you can route it to the test output:

public class WriteLogTests
{
    private readonly ITestOutputHelper _output;

    public WriteLogTests(ITestOutputHelper output)
    {
        _output = output;
    }

    [Fact]
    public void WriteLogRendersBuildLog()
    {
        var actual = Model.WriteLog(_output.WriteLine).Create<Person>();

        actual.Should().NotBeNull();
    }
}

The log shows the build tree — each type entered, each member created, and any member skipped by an ignore rule, a circular-reference guard or the depth guard.

Inspecting the structured log

WriteLog(Action<string>) renders the log to text, but the log is also a structured tree you can inspect programmatically. An IBuildLog exposes Entries, a list of BuildLogEntry nodes that each carry a Kind (CreateInstance, PopulateInstance, CreateValue or SkipMember), the TargetType, an optional MemberName and Reason, and nested Children. This is the same tree captured on a ModelBuildException, so you can assert on exactly what the builder did — for example that a member was skipped for the expected reason — rather than parsing rendered text:

static bool WasSkipped(IReadOnlyList<BuildLogEntry> entries, string memberName) =>
    entries.Any(e =>
        (e.Kind == BuildLogEntryKind.SkipMember && e.MemberName == memberName)
        || WasSkipped(e.Children, memberName));

Handling build failures

When a build cannot complete, ModelBuilder throws a ModelBuildException. As well as the message, it carries structured context so you can diagnose the failure without a debugger:

Member Description
FailureKind A FailureKind enum categorising the cause: InaccessibleConstructor, UnmappedAbstractType, NoValueSource, NoBuilderForType, CircularReference, DepthExceeded, ValueSourceThrew or Unspecified.
TargetType The type being built when the failure occurred, or null.
TargetMember The member being built when the failure occurred, or null.
BuildPath The BuildFrame chain from the root type down to the failing member.
BuildLog The structured build log captured up to the failure.

Branch on FailureKind rather than the message so your handling survives wording changes:

try
{
    var parcel = Model.Create<Parcel>();
}
catch (ModelBuildException ex) when (ex.FailureKind == FailureKind.UnmappedAbstractType)
{
    // A reachable abstract/interface member has no mapping — add a Model.Mapping<,> for it.
    Console.WriteLine($"Map {ex.TargetType} before building (at {ex.TargetMember}).");
}

Configuration modules

An IConfigurationModule packages reusable build configuration so it can be shared across tests. A module receives an IBuildConfiguration and adds type mappings, ignore rules and custom value sources; the built-in value sources are always applied, so a module only declares the deltas it needs.

public sealed class TestModule : IConfigurationModule
{
    public void Configure(IBuildConfiguration configuration)
    {
        // Use a concrete type wherever IShipment is needed.
        configuration.AddMapping<IShipment, GroundShipment>();

        // Never populate Request.Data.
        configuration.Ignore(typeof(Request), nameof(Request.Data));

        // Ignore any member matching a predicate, across all types.
        configuration.IgnoreAny(member => member.Name.EndsWith("Internal", StringComparison.Ordinal));
    }
}

Apply a module per build, and chain modules to layer configuration:

var model = Model.UsingModule<TestModule>().Create<Account>();

var layered = Model.UsingModule<UnitTestModule>()
    .UsingModule<IntegrationTestModule>()
    .Create<Account>();

Default configuration

A build with no configuration — Model.Create<T>() with no module, ignore rule, mapping or option override — starts from these defaults. A module, or a fluent call on Model, only adds deltas on top:

Category Default
Type mappings None. Abstract and interface members are unbuildable until you add a Mapping<,>.
Ignore rules None. Every settable member and constructor parameter is populated.
Custom value sources None registered, but the built-in value sources always apply (you never register them, and a custom source only overrides the built-in for its type/name).
Built-in typed sources bool; every numeric type (byte/sbyte/short/ushort/int/uint/long/ulong/float/double/decimal); char; string; Guid; DateTime; DateTimeOffset; TimeSpan; Uri; Version; byte[]. Enums, nullables and collections are handled by the generator.
Built-in named sources The entity-style member-name sources in Built-in data (names, email, company, location fields, age, date of birth, …).
Build options MinCount 1, MaxCount 10, NullPercentage 5, MaxDepth 50, UseConstructorDefaults off, RetainAssignedValues off — see Tuning the build.

Tuning the build

A few build-wide settings live on BuildOptions: the collection-size range (MinCount/MaxCount), the chance a nullable value is produced as null (NullPercentage, 0–100), and the maximum graph depth (MaxDepth). Tune them with Model.SetOptions, which returns the same fluent configuration as the other entry points:

// Always populate nullables, and make collections hold exactly three items.
var order = Model.SetOptions(x =>
    {
        x.NullPercentage = 0;
        x.MinCount = 3;
        x.MaxCount = 3;
    })
    .Create<Order>();

The same call is available on the IBuildConfiguration a configuration module receives, so a module can set defaults that every build using it inherits:

public sealed class CompactModule : IConfigurationModule
{
    public void Configure(IBuildConfiguration configuration)
    {
        configuration.SetOptions(x => x.MaxCount = 3);
    }
}

MinCount/MaxCount are coerced when a collection length is drawn — a negative minimum becomes zero and a maximum below the minimum is raised to it — so setting MaxCount without MinCount cannot throw. The defaults are MinCount 1, MaxCount 10, NullPercentage 5 and MaxDepth 50.

UseConstructorDefaults (off by default) controls how the automatic Model.Create<T>() path fills an optional constructor parameter. Off, a value is generated for it like any other member; on, the parameter's declared default value is used instead:

// Widget(string label, int size = 7) — size uses its default of 7 instead of a generated value.
var widget = Model.SetOptions(x => x.UseConstructorDefaults = true).Create<Widget>();

This applies only to the constructor Create<T>() selects. The explicit Construct<T>().From(...) path always gives per-argument control regardless of this option, because its generated overloads expose each optional parameter's default.

RetainAssignedValues (off by default) controls whether a settable member that already holds a non-default value is kept instead of being overwritten with a generated value. Off, every settable member is populated with a generated value, including one a constructor or property initializer assigned — so a property newed up with an empty instance (public Address Address { get; set; } = new();) still has its own members filled in. Turn it on to preserve assigned values, which also keeps a more derived instance assigned to a less derived member:

public sealed class Owner
{
    public Owner() => Pet = new Dog();   // Dog : Animal

    public Animal Pet { get; set; }
}

// With the option on, Pet keeps the Dog the constructor assigned rather than being replaced with a
// generated Animal. Off (the default), Pet is rebuilt as a generated Animal.
var owner = Model.SetOptions(x => x.RetainAssignedValues = true).Create<Owner>();

A member counts as assigned only when it differs from default for its type, so a null reference member or a value member equal to its zero value is always generated and cannot be distinguished from an unset one. This runtime check is separate from constructor-parameter matching, which already excludes matched members from the generated population code at compile time.

Built-in data

ModelBuilder ships value sources for the common primitive and BCL types (bool, the numeric types, char, string, Guid, DateTime, DateTimeOffset, TimeSpan, Uri, Version, byte[]), plus enum, nullable and collection support that the generator wires up automatically.

Entity-style data matched by member name

Members whose name matches a known entity field are filled with embedded reference data rather than random noise, so the values read naturally. Matching is on a whole PascalCase/camelCase word and is case-insensitive, so ContactEmailAddress matches Email, while a short field name like PageCount is not treated as an Age.

Member name (with aliases) Generated value
FirstName, GivenName A given name
LastName, Surname, FamilyName A family name
MiddleName A given name
FullName, Name, DisplayName First Last
UserName, Username, Login first.last with a numeric suffix, lower-cased
Email first.last@domain
Domain A domain
Company, Business A company name
Country, Region, CountryRegion A country
State, Province A state or province
City, Suburb, Town A city
PostCode, ZipCode, Postcode, Zip A post/zip code
Phone, Mobile, Cell, Fax A phone number
TimeZone A time-zone identifier
Age An integer in the human-plausible range 1–100
DateOfBirth, DOB, BirthDate, Birthday A UTC date for a 1–100 year old

A member named like one of these but typed differently from what the source produces (for example an Age that is a string rather than an int) falls through to the ordinary type-based sources, so the name match never produces a type mismatch.

Cross-field consistency

Related members on the same instance agree with each other instead of being sampled independently, and the agreement is order-independent — it does not matter which related member the generator builds first:

  • Names flow into email, username and full name. Email is built from the FirstName, LastName and Domain already set on the object (falling back to fresh data when they are absent), and FullName/UserName are composed from the same name fields. A model that spells the members GivenName/Surname still gets a matching email.
  • One coherent location per instance. Country, State, City, PostCode and Phone are drawn from a single built-in Location row, so an instance never produces an impossible combination such as London, New South Wales, India. A different instance elsewhere in the graph gets its own location.
  • Age follows date of birth. DateOfBirth is the definitive value; Age is the number of completed years between it and a fixed reference date, so a generated Age of 18 can never sit next to a DateOfBirth that implies 35.

Reusable reference data sets

The same embedded data the built-in sources draw from is exposed through the static ModelBuilder.Data.TestData class, so you can use it directly in your own value sources, fixtures or assertions. Each property is a cached IReadOnlyList<> read once from embedded resources:

TestData member Contents
MaleNames, FemaleNames Given names
LastNames Family names
Companies Company names
Domains Email/web domains
TimeZones Time-zone identifiers
Locations Location rows pairing City, State, Country, PostCode, Phone, StreetName and StreetSuffix so a drawn location is internally consistent
var random = new RandomSource();
var location = TestData.Locations[random.NextInt32(0, TestData.Locations.Count - 1)];

// location.City, location.State, location.Country, location.PostCode all belong together.

Location (in the same ModelBuilder.Data namespace) is a plain mutable type. You can build your own instances with an object initializer, or parse one from a CSV line with Location.Parse. The CSV columns are comma-separated in this exact order:

Country,State,City,PostCode,StreetName,StreetSuffix,Phone
var custom = new Location { City = "Hobart", State = "Tasmania", Country = "Australia" };
var parsed = Location.Parse("Australia,Tasmania,Hobart,7000,Murray,Street,03 6200 0000");

These are for your own value sources and fixtures. The built-in TestData lists (including Locations) are read-only, so there is no public API to add your own entries to the data the built-in location source draws from — to inject custom location data into a build, register a custom value source for the relevant members.

Generating random values

ModelBuilder's randomness comes from IRandomSource, implemented by RandomSource. It is the Random seam on IBuildContext, but you can also use it standalone — for direct random values or to make a build reproducible by seeding it. It is thread-safe, returns each value type directly without boxing, and generates wide-integer and decimal ranges with their own arithmetic so there is no precision loss through double:

// A default source is independently seeded; pass a seed to reproduce a sequence.
var random = new RandomSource(seed: 1234);

int dice = random.NextInt32(1, 6);          // every range method is inclusive of both bounds
decimal price = random.NextDecimal(0m, 100m);
bool flag = random.NextBool();

var buffer = new byte[16];
random.NextBytes(buffer);

Typed methods cover the full numeric range — NextByte, NextSByte, NextInt16/NextUInt16, NextInt32/NextUInt32, NextInt64/NextUInt64, NextSingle, NextDouble, NextDecimal — plus NextBool and NextBytes. The integer and floating-point overloads default to the type's full range, and the Seed property reports the seed in use so a failing test can be replayed.

Target frameworks

The library targets netstandard2.0, net8.0, net9.0 and net10.0. The source generator and the generated code use no reflection, so a consuming application can publish with Native AOT and full trimming with no IL2xxx/IL3xxx warnings.

Migrating from v8

The previous reflection-based extensibility pipeline (IExecuteStrategy, IConstructorResolver, ITypeCreator, IValueGenerator, IPostBuildAction, the per-ParameterInfo/PropertyInfo overloads and so on) has been removed in favour of the source-generated model and a small set of supported seams (IValueSource<T>, mappings, ignore rules and [GenerateModelBuilder]). The migration guide maps every changed and removed v8 API to its v9 equivalent.

For an automated upgrade, the package also ships an AI-agent playbook (MIGRATION-AI.md) and a machine-readable map (migration-map.json) so an agent or tool can apply the migration deterministically.

Product Compatible and additional computed target framework versions.
.NET net5.0 was computed.  net5.0-windows was computed.  net6.0 was computed.  net6.0-android was computed.  net6.0-ios was computed.  net6.0-maccatalyst was computed.  net6.0-macos was computed.  net6.0-tvos was computed.  net6.0-windows was computed.  net7.0 was computed.  net7.0-android was computed.  net7.0-ios was computed.  net7.0-maccatalyst was computed.  net7.0-macos was computed.  net7.0-tvos was computed.  net7.0-windows was computed.  net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 is compatible.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  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. 
.NET Core netcoreapp2.0 was computed.  netcoreapp2.1 was computed.  netcoreapp2.2 was computed.  netcoreapp3.0 was computed.  netcoreapp3.1 was computed. 
.NET Standard netstandard2.0 is compatible.  netstandard2.1 was computed. 
.NET Framework net461 was computed.  net462 was computed.  net463 was computed.  net47 was computed.  net471 was computed.  net472 was computed.  net48 was computed.  net481 was computed. 
MonoAndroid monoandroid was computed. 
MonoMac monomac was computed. 
MonoTouch monotouch was computed. 
Tizen tizen40 was computed.  tizen60 was computed. 
Xamarin.iOS xamarinios was computed. 
Xamarin.Mac xamarinmac was computed. 
Xamarin.TVOS xamarintvos was computed. 
Xamarin.WatchOS xamarinwatchos was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • .NETStandard 2.0

    • No dependencies.
  • net10.0

    • No dependencies.
  • net8.0

    • No dependencies.
  • net9.0

    • No dependencies.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
8.7.0-beta.8 37 6/27/2026
8.7.0-beta.7 41 6/27/2026
8.6.0 27,884 3/17/2024
8.5.0 278 2/27/2024
8.4.0 12,522 8/9/2022
8.4.0-beta0001 293 8/9/2022
8.3.0 1,623 6/22/2022
8.2.0 17,469 8/29/2021
8.1.1-beta0001 388 8/29/2021
8.1.0 2,693 5/7/2021
8.1.0-beta0001 478 5/7/2021
8.0.0 877 4/17/2021
8.0.0-beta0087 391 4/17/2021
8.0.0-beta0057 412 4/17/2021
8.0.0-beta0028 409 4/17/2021
7.4.2-beta0001 443 4/17/2021
7.4.1 1,590 3/30/2021
7.4.1-beta0001 413 3/30/2021
7.4.0 1,272 1/2/2021
7.3.1-beta0001 468 1/2/2021
Loading failed