nullean.entities · .net

Types hold data.Nothing constrains it beyond its type.

Write the rules once, in plain C#. Nullean.Entities turns them into validation, state machines and thousands of generated tests. CsCheck drives them, shrinks every failure to its smallest case, and the report is clear enough for an AI to fix.

dotnet add package Nullean.Entities
nuget ↗
dotnet add package Nullean.Entities.CsCheck
nuget ↗

The core has no dependencies. Add CsCheck to a test project for the generated tests.

Code first. A plain C# record gains constraints, relations, a state machine, time rules and a service, and each line of code adds a dimension. One line then generates the tests with CsCheck, and finally every rule has been reached.

Every layer in place: code that states what the types cannot, and what it drives.

  1. 0Types
  2. 1Relations
  3. 2Constraints
  4. 3State machine
  5. 4Temporality
  6. 5Services
  7. 6AutoTests
  8. 7Confidence

Order.csplain C#. no database.
public record Order(   // 0 · a record: shape, nothing more
    Guid Id, Guid CustomerId,
    IReadOnlyList<Line> Lines,
    OrderStatus Status, int Version, decimal Total, string Label);// 1 · rules between rows, written next to the types
public static readonly Constraint<Shop> LinesPointAtAnOrder =
    Constraints<Shop>.References(
    s => s.Lines, l => [l.OrderId], s => s.Orders.Select(o => o.Id));
public static readonly Constraint<Shop> OrderIdsAreUnique =
    Constraints<Shop>.Unique(s => s.Orders, o => o.Id);// 2 · rules about values, with a name and a sentence
public static readonly Constraint<Order> TotalNeverNegative =
    Constraints<Order>.Always(
    o => o.Total, t => t >= 0, "An order total is never negative.");
public static readonly Constraint<Order> MoneyIsConserved =
    Constraints<Order>.Always(
    o => o.Lines.Sum(l => l.Qty * l.Price) == o.Total,
    "The total is the sum of the lines.");// 3 · what may follow what
public static readonly StateMachine<OrderStatus, string> Lifecycle =
    StateMachine.Define<OrderStatus, string>(OrderStatus.Draft)
    .Permit("place", OrderStatus.Draft, OrderStatus.Placed)
    .Permit("pay", OrderStatus.Placed, OrderStatus.Paid)
    .Permit("ship", OrderStatus.Paid, OrderStatus.Shipped)
    .Permit("cancel", [OrderStatus.Placed, OrderStatus.Paid],
        OrderStatus.Cancelled)
    .Build();// 4 · time: never backwards, and when a rule bites
public static readonly Constraint<Order> VersionNeverGoesBack =
    Constraints<Order>
    .Monotonic(o => o.Version, MonotonicDirection.NonDecreasing)
    .WithSeverityUntil(Severity.Warning,
        new DateTimeOffset(2027, 1, 1, 0, 0, 0, TimeSpan.Zero), Severity.Error);// 5 · the same rules on a mutable, async service
public static readonly ConstraintSet<Wallet> WalletRules =
    ConstraintSet
    .From(("money-is-conserved",
        Constraints<WalletState>.Conserves(s => s.Checking + s.Savings)))
    .Map<Wallet>(w => w.State);
await AutoTest.For(WalletRules, () => new Wallet())
    .MutateAsync<int>("Move", (w, n) => w.MoveAsync(n), _ => Gen.Int[1, 12])
    .RunAsync();// 6 · one line: CsCheck generates, shrinks, replays
await AutoTest.For(OrderConstraints.Instance).RunAsync();// 7 · nothing more to write.
// every rule was reached.
think of it asa plain shape
OrderId GuidCustomerId GuidLines Line[]Status OrderStatusVersion intTotal decimal Total never negative? Cancelled, then Shipped? The same call twice? a record cannot say. CustomerIdName OrderIdCustomerIdTotalStatusVersion LineIdOrderIdQtyPrice n → 1n → 1 Line l1 → Order o1 ✓ Line l2 → Order o1 ✓ Line l3 → Order o7 ✕ missing Line l3 → Order o1 ✓ UniqueReferencesAcyclicNoChains total-never-negative Total = -5 → rejectedTotal = -5 → rejected money-is-conservedlines sum to Total Rejected: Order.Total = -5Rule broken: total-never-negative Draft Placed Paid Shipped Cancelled denied version-never-goes-down v1 → v2 → v3 → v4, never back · warning until 2027-01-01 Rejected: Version 4 → 3warning now, error from 2027-01-01 Wallet · 1 action leak shrunk from 6money conserved ✓ Move(1)the smallest case that loses a unit Exhaustiveevery state × every trigger Samplerandom valid values, seeded Walksrandom legal walks of the machine Conformthe code matches the machine Commutesthe order of calls does not matter CsCheck generates the cases · shrinks failures · replays by seed ✓ Exhaustive ✓ Sample ✓ Walks ✓ Conform ✓ Commutes ✓ every rule was reached 9

and on a record

Write the rules. Run one line. Plant a bug.

Five named rules, a check where a value is built, and a single test line. Plant a bug and read what the generated test says.

OrderConstraints.csthe rules, written once
[Constraints<Order>]
public static partial class OrderConstraints
{
    public static readonly Constraint<Order> TotalNeverNegative = Constraints<Order>.Always(
        o => o.Total, t => t >= 0, "An order total is never negative.");

    public static readonly Constraint<Order> PlacedOrdersHaveLines = Constraints<Order>
        .When(o => o.Status == OrderStatus.Placed)
        .Requires(o => o.Lines.Count > 0, "A placed order has at least one line.");

    public static readonly Constraint<Order> LegalMoves = Constraints<Order>
        .Transitions(o => o.Status)
        .Allow(OrderStatus.Draft, OrderStatus.Placed)
        .Allow(OrderStatus.Placed, OrderStatus.Paid)
        .Allow(OrderStatus.Paid, OrderStatus.Shipped)
        .Allow(OrderStatus.Placed, OrderStatus.Cancelled)
        .Allow(OrderStatus.Paid, OrderStatus.Cancelled);

    public static readonly Constraint<Order> CancelIsRepeatable =
        Constraints<Order>.Idempotent(o => o.Cancel());

    public static readonly Constraint<Order> VersionNeverGoesBack =
        Constraints<Order>.Monotonic(o => o.Version, MonotonicDirection.NonDecreasing);
}
OrderFactory.cscheck a value where it is built
var order = new Order(Guid.NewGuid(), Guid.NewGuid(), [],
    OrderStatus.Placed, 1, -5m, "");

Result<Order> result = OrderConstraints.All.Validated(order);
// total-never-negative: An order total is never negative.
// placed-orders-have-lines: A placed order has at least one line.
OrderTests.csthe whole test · driven by CsCheck
await AutoTest.For(OrderConstraints.Instance).RunAsync();
Same rules, same test. Change the code under test:
Order.csone method added
public Order Reset() => this with { Version = 0 };
dotnet test

// plant a bug above, then read what the generated test says

Monotonicity failed question 3 · Does it ever go backwards?
Monotonicity failed.​Rule broken   version-never-goes-back: Expected NonDecreasing but moved from 1 to 0.Operation     Reset()Started from  Order { Lines = [], Status = Cancelled, Version = 1, Total = 0, Label = "" }Produced      Order { Lines = [], Status = Cancelled, Version = 0, Total = 0, Label = "" }​Law           No operation moves a value that may only go one way in the other direction.Consequence   An older result overwrites a newer one, a counter goes back or a timestamp regresses, without anything saying so.​Smallest case found, after 4 shrinks. To run this case again: .Seed("7ub38ErvzD2a")

the differentiator

Nine axes almost no test suite asks about.

Most tests check whether one example works. The generated tests ask these nine, and each one catches a specific kind of real production bug. Hover, focus or tap a row to watch the bug happen.

The nine questions and the bug each catches
#AxisQuestionThe bug it catchesAnimation
1Invariant an operation that lets a bad value in
2Idempotency a retry that changes state twice 12
3Monotonicity an older result overwriting a newer one
4Conservation totals that drift ≠total
5Absence a cancelled order that still ships CancelledShipped
6Temporal a promise missed by one unlucky gap deadline
7Liveness the endless spinner
8Confluence two rebuilds publishing different results a babb aba≠
9Vacancy a green test that checked nothing ✓ greenrulenever reached

when it breaks

A failure you can read. So can your AI.

Each failure names the rule, the operation and the starting value, states the law that was broken and what it costs in production, and arrives shrunk to the smallest case with a seed that replays it (shrinking and seeds come from CsCheck). Rich enough for an agent to read, fix the code and replay.

Monotonicity failedquestion 3
generated value

Illustrative path. The report states only the first value shape and the smallest case; ▒ stands for generated data.

Monotonicity failed.​Rule broken   version-never-goes-back: Expected NonDecreasing but moved from 1 to 0.Operation     Reset()Started from  Order { Lines = [], Status = Cancelled, Version = 1, Total = 0, Label = "" }Produced      Order { Lines = [], Status = Cancelled, Version = 0, Total = 0, Label = "" }​Law           No operation moves a value that may only go one way in the other direction.Consequence   An older result overwrites a newer one, a counter goes back or a timestamp regresses, without anything saying so.​Smallest case found, after 4 shrinks. To run this case again: .Seed("7ub38ErvzD2a")
Temporal failedquestion 6
Temporal failed.​Rule broken      confirmed-ships-within-a-minuteNoticed at       +60sWhat happened    No matching event within 00:01:00 after ConfirmedEvents recorded  +0s  Confirmed                 +90s  ShippedCalls made       wait 1s, Confirm​Law              What must follow something within a deadline does, however the time between calls is spent.Consequence      A deadline is missed under timing the tests never tried, so a stuck state is reported as in progress for good.​Smallest case found, after 5 shrinks. To run this case again: .Seed("0i1vmP3ChLc5")
  1. 1 A rule fails and prints the report.
  2. 2 The agent reads the rule, the operation and the law.
  3. 3 The agent fixes the code.
  4. 4 .Seed("7ub38ErvzD2a") replays the exact case.

one declaration

One rule, three jobs.

You wrote TotalNeverNegative once. You did not write it again in a validator, a test and a monitor.

TotalNeverNegative
01

It validates

The rule checks a value where it is built, so a bad value is stopped at the door instead of three calls later.

02

It tests itself

The rule's metadata is generated into CsCheck tests and run against machine-generated data. You write no test code for it.

await AutoTest.For(OrderConstraints.Instance).RunAsync();
03

It can watch

The same rule can watch a live stream of events for the same breach.

opt-in

It works on immutable records and on services or classes that change in place, and it awaits async operations.

gradual adoption

Start as a warning. Become an error on a date you choose.

Nothing breaks on the day you add a rule. Findings are reported until the date you set, and from then on they are errors.

Severity
public static readonly Constraint<Order> Start =
    Constraints<Order>.Always(o => o.Total, t => t >= 0)
    .WithSeverity(Severity.Warning);   // start: nothing breaks, findings are reported
public static readonly Constraint<Order> OnADate =
    Constraints<Order>.Always(o => o.Total, t => t >= 0)
    .WithSeverityUntil(Severity.Warning,
        new DateTimeOffset(2026, 12, 1, 0, 0, 0, TimeSpan.Zero), Severity.Error);   // error from this date
Severity by date
DateSeverity
before 2026-12-01Warning
from 2026-12-01Error
2026-11-15
WARNING

Findings are reported. Nothing breaks.

two kinds of data

Adversarial and real-world data, judged by the same rules.

CsCheck drives the tests: its generators hunt for the case you did not think of. Bogus fills the same types with data that looks like production. Neither needs its own rules.

adversarial · CsCheck

Hunts the case you did not think of.

  • Starts at the edges: empty, zero, one, the largest, out of order.
  • Shrinks every failure to the smallest case.
  • Replays it from a seed.
emptyzeroonelargestnegativerepeatedout of order
Order { Lines = [], Status = Cancelled, Version = 1, Total = 0, Label = "" }

real world · Bogus

Looks like Tuesday in production.

  • Names, addresses, emails, amounts and dates shaped like your customers.
  • Finds rules that are too strict, or too loose, for data that actually exists.
  • Failures read like records someone would recognise.
nameaddressemailamountdatelabel
The same constraints judge both:ConfirmedOrdersHaveLinesLegalMovesVersionNeverGoesBackTotalNeverNegativeCancelIsRepeatable

a quick walkthrough

One Order, built up in seven stages.

Step through it, or let it play. Each stage keeps what came before and adds one thing. Plant a bug at stages 2, 4 and 5.

One Order, built up in seven stages: from types only to a system you can trust

stage 0 · types only

Types hold data.

A record gives the Order its shape and stops there. It cannot say how rows relate, which values are allowed, what may follow what, or what a repeated call does.

Order.cstypes only
public enum OrderStatus { Draft, Placed, Paid, Shipped, Cancelled }

public record Order(
    Guid Id, Guid CustomerId,
    IReadOnlyList<Line> Lines,
    OrderStatus Status, int Version, decimal Total, string Label);
a valuelegal, as far as the compiler knows
Order {
  Id = o1, CustomerId = c1, Lines = [],
  Status = Cancelled, Version = 1, Total = 0, Label = ""
}
  • Total = -5compiles
  • Status = Shipped, Lines = []compiles
  • Cancelled → Shippedcompiles
  • Total never negative? answered at stage 2
  • Cancelled, then Shipped? answered at stage 3
  • The same call twice? answered at stage 4

stage 1 · relations

Rows point at rows.

An Order has many Lines, and each Line carries a foreign key. Four constraints cut across rows: Unique, References, Acyclic and NoChains. A dangling Line.OrderId turns red.

Line.cs+ added at this stage
public record Line(
    Guid Id, Guid OrderId,
    string Sku, int Qty, decimal Price);

public record Shop(
    IReadOnlyList<Order> Orders, IReadOnlyList<Customer> Customers,
    IReadOnlyList<Category> Categories)
{
    public IEnumerable<Line> Lines => Orders.SelectMany(o => o.Lines);
}

[Constraints<Shop>]
public static partial class ShopConstraints
{
    public static readonly Constraint<Shop, Guid>
        OrderIdsAreUnique = Constraints<Shop>.Unique(s => s.Orders, o => o.Id);

    public static readonly Constraint<Shop, Guid>
        LinesPointAtAnOrder = Constraints<Shop>.References(
            s => s.Lines, l => [l.OrderId], s => s.Orders.Select(o => o.Id));
    // Acyclic and NoChains have the same shape: see the relations section
}
Order 1 — n LineReferences
  1. l1OrderId = o1 References
  2. l2OrderId = o1 References
  3. l3OrderId = o7 References: no Order has Id o7

stage 2 · constraints

Name the rules, next to the type.

A rule is a value with a name and a sentence. Enter a bad Total and it is rejected on the spot; the violation names the rule and the member.

OrderConstraints.cs+ added at this stage
[Constraints<Order>]
public static partial class OrderConstraints
{
    public static readonly Constraint<Order>
        TotalNeverNegative = Constraints<Order>.Always(
            o => o.Total, t => t >= 0,
            "An order total is never negative.");

    public static readonly Constraint<Order>
        MoneyIsConserved = Constraints<Order>.Always(
            o => o.Lines.Sum(l => l.Qty * l.Price) == o.Total,
            "The total is the sum of the lines.");
}
try itLines sum to 42
  • total-never-negative holds
  • money-is-conserved holds
Rejected: Order.Total = -5Rule broken   total-never-negativeMember        TotalMessage       An order total is never negative.Rule broken   money-is-conservedMessage       The total is the sum of the lines.

Rule names, members and messages are what Validate returns. The Rejected line is a label.

stage 3 · state machine

Draw what may follow what.

Draft, Placed, Paid, then Shipped or Cancelled. The diagram is the spec: the edge that is not there, Cancelled to Shipped, is denied. Mermaid goes in, and the same Mermaid comes out.

OrderMachine.cs+ added at this stage
public static readonly
    StateMachine<OrderStatus, string> Lifecycle = StateMachine
        .Define<OrderStatus, string>(OrderStatus.Draft)
        .Permit("place", OrderStatus.Draft, OrderStatus.Placed)
        .Permit("pay", OrderStatus.Placed, OrderStatus.Paid)
        .Permit("ship", OrderStatus.Paid, OrderStatus.Shipped)
        .Permit("cancel", [OrderStatus.Placed, OrderStatus.Paid],
            OrderStatus.Cancelled)
        .Build();
// no edge from Cancelled to Shipped: denied
Lifecyclethe diagram is the spec
placepayshipcancelcancelDraftPlacedPaidShippedCancelleddenied: Cancelled → Shippedthe implementation allows it

The same diagram can be read from, and written to, Mermaid.

stage 4 · autotests

Write constraints. Get tests.

One line runs a test for each axis your rules declare. Add .Deeper() for Exhaustive, Faults and Conform on the state machine, and .Commutes() for order. All of it is generated from the constraints and the machine, driven by CsCheck. Code your AI can generate and you can read.

OrderTests.cs+ added at this stage
await AutoTest.For(OrderConstraints.Instance).RunAsync();   // the axes the rules declare
Lifecycle.Spec().Exhaustive();                              // every state reached, every edge taken
// Sample, ConformTo and Commutes are one more line each,
// built from the same constraints and the same machine
dotnet testgenerated
  1. Exhaustiveevery state with every trigger20 pairs
  2. Samplegenerated Orders, shrunk on failure1000 cases
  3. Walksrandom legal paths through the machine500 walks
  4. Conformthe implementation against the diagram20 pairs
  5. Commutesdoes order of input change the output200 orderings

Conform failed. The implementation allows Cancelled → Shipped. The diagram denies it.

20 pairs is 5 states × 4 triggers. Other counters are illustrative.

stage 5 · services

Lift the same rules onto a service.

A mutable, async Wallet keeps its state in place. A one-unit leak shrinks to the smallest failing sequence of actions, with a seed that replays it.

Wallet.cs+ added at this stage
public record WalletState(int Checking, int Savings);

public class Wallet
{
    public WalletState State => new(_checking, _savings);
    private int _checking = 30, _savings = 10;

    public async Task MoveAsync(int amount)
    {
        await Task.Yield();
        if (amount <= 0 || amount > _checking) return;
        _checking -= amount;
        _savings += amount;
-       _savings += amount;
+       _savings += amount - 1;
    }
}

// rules are written against a state; Map says where it lives
public static readonly ConstraintSet<Wallet> WalletRules =
    ConstraintSet
    .From(("money-is-conserved",
        Constraints<WalletState>.Conserves(s => s.Checking + s.Savings)))
    .Map<Wallet>(w => w.State);
await AutoTest.For(WalletRules, () => new Wallet())
    .MutateAsync<int>("Move", (w, n) => w.MoveAsync(n), _ => Gen.Int[1, 12])
    .Steps(1, 6)
    .RunAsync();
dotnet testWallet · money-is-conserved
sampling action sequences
Conservation failed.​Rule broken   money-is-conserved did not conserve its value (was 40, became 39).Operation     Move(1)Started from  Wallet { State = WalletState { Checking = 30, Savings = 10 } }Produced      Wallet { State = WalletState { Checking = 29, Savings = 10 } }​Law           No operation creates or destroys a quantity that must be conserved.Consequence   Totals drift: something appears or vanishes between the two halves of a change.​Smallest case found, after 1 shrink. To run this case again: .Seed("7DTWL8nvnax7")

Conserved across every sampled action sequence.

The report is real output for this Wallet. The shrink path above it is illustrative.

stage 6 · confidence

Not hope. Evidence.

Nine questions asked of every rule, and one more: did the tests reach the rule at all? Green means something because vacancy is checked too.

CInothing new to write
dotnet test
# the same rules and the same line run on every change,
# human or agent.
dotnet testall green
  1. 1Invariantpasses
  2. 2Idempotencypasses
  3. 3Monotonicitypasses
  4. 4Conservationpasses
  5. 5Absencepasses
  6. 6Temporalpasses
  7. 7Livenesspasses
  8. 8Confluencepasses
  9. 9Vacancypasses
  • total-never-negative
  • money-is-conserved
  • Lifecycle
  • Wallet

Vacancy every rule was reached.

Illustration of an all-green run.

    Drag the scrubber, or press ← → on it. Code is a sketch of the shape; type names are the library's, signatures are illustrative.

    your domain, as a database model

    Think of your domain as a database model. Say its constraints.

    Orders, Lines, Customers and Categories behave like tables with keys, and the rules between them are usually implicit: a key that must exist, an id that must be unique, a tree that must not loop. This library lets you encode those rules next to the types. Break one below and the violation points at the member.

    Customer · Order · Line · Categoryentity relationships
    Customer Id Name MergedIntoId Order Id CustomerId Total Line Id OrderId Qty 1n 1n Order.Id o1 appears twice OrderId = o7 · no such Order Category.ParentId · child points at parent Root Fiction Poetry Root → Poetry → Fiction → Root Customer.MergedIntoId · merged into c1 c2 c3 c1 → c2 → c3 is a chain
    Break something:
    Relations.csacross rows
    [Constraints<Shop>]
    public static partial class ShopConstraints
    {
        public static readonly Constraint<Shop, Guid> OrderIdsAreUnique =
            Constraints<Shop>.Unique(s => s.Orders, o => o.Id);    public static readonly Constraint<Shop, Guid> LinesPointAtAnOrder =
            Constraints<Shop>.References(
                s => s.Lines, l => [l.OrderId], s => s.Orders.Select(o => o.Id));    public static readonly Constraint<Shop> CategoriesHaveNoCycles =
            Constraints<Shop>.Acyclic(
                s => s.Categories, c => c.Id, c => c.ParentId is { } p ? [p] : []);    public static readonly Constraint<Shop> MergesAreNotChained =
            Constraints<Shop>.NoChains(
                s => s.Customers, c => c.Id, c => c.MergedIntoId ?? c.Id);}
    dotnet test

    // break something on the left; the violation points at the member

    Unique failed.Rule broken   order-ids-are-uniqueMember        OrdersWhat happened order-ids-are-unique: 'o1' appears 2 times.
    References failed.Rule broken   lines-point-at-an-orderMember        LinesWhat happened lines-point-at-an-order: 'o7' refers to nothing.
    Acyclic failed.Rule broken   categories-have-no-cyclesMember        CategoriesWhat happened categories-have-no-cycles: cycle Root -> Poetry -> Fiction -> Root.
    NoChains failed.Rule broken   merges-are-not-chainedMember        CustomersWhat happened merges-are-not-chained: 'c1' maps to 'c2', which maps onwards.

    Real rule names, members and wording from Validate, with the Guids shortened to o1, l3 and so on.

    your domain, as a state machine

    Think of your domain and services as a state machine. Encode it, visualize it.

    What may follow what is usually implicit too. This library lets you encode it once, check the implementation against it, and print the Mermaid to boot.

    StateMachine<TState, TTrigger>

    Values moved by named triggers.

    Lifecycledraft → …
    placepayshipcancelcancelDraftPlacedPaidShippedCancelleddenied: Cancelled → Shippedthe implementation allows it

      Order.csthe implementation
      public Order Ship() => Status is OrderStatus.Paid
          ? this with { Status = OrderStatus.Shipped }
          : this;Status is OrderStatus.Paid or OrderStatus.Cancelled   // Cancelled slipped in
          ? this with { Status = OrderStatus.Shipped }
          : this;
      
      Lifecycle.ConformTo(Order.Draft(), o => o.Status, bindings: [
          ("place", o => o.Place()), ("pay", o => o.Pay()),
          ("ship", o => o.Ship()), ("cancel", o => o.Cancel())]);
      dotnet test

      ConformTo passed. Every edge the implementation allows is in the diagram, and every denied edge is refused.

      Implementation diverged from the specification at step 4Reason after ship in Cancelled the machine says Cancelled, but the entity is in ShippedTrace           Draft     1 place         Placed     2 cancel         Cancelled     3 cancel         Cancelled >>  4 ship         CancelledSet seed: "2jBvK6qtRCw8" or -e CsCheck_Seed=2jBvK6qtRCw8 to reproduce (0 shrinks, 77 skipped, 100 total).

      Real ConformTo output for this bug. The seed replays it.

      And the Mermaid comes out too
      Lifecycle.mmdprinted from the machine
      stateDiagram-v2
          [*] --> Draft
          Draft --> Placed : place
          Placed --> Paid : pay
          Paid --> Shipped : ship
          Placed --> Cancelled : cancel
          Paid --> Cancelled : cancel

      Print the machine as Mermaid to put the diagram in your docs, or read a Mermaid diagram in to build the machine.

      StateMachine<TState>

      Case types of a union or record hierarchy, moved by guards.

      Checkoutguards on the edges
      [Lines.Count > 0][Address complete]CartAddressedPaiddenied: Paid → Cart
      Checkout.cscase types
      public abstract record Checkout;
      public record Cart(IReadOnlyList<Line> Lines) : Checkout;
      public record Addressed(
          IReadOnlyList<Line> Lines, Address To) : Checkout;
      public record Paid(Guid PaymentId) : Checkout;
      
      public static readonly StateMachine<Checkout> CheckoutMachine =
          StateMachine<Checkout>.Define(m => m
          .Initial<Cart>(s => s.To<Addressed>(c => c.Lines.Count > 0))
          .State<Addressed>(s => s.To<Paid>(a => a.To.IsComplete))
          .Terminal<Paid>());

      not just records

      Test services, not just records.

      Most business logic lives in mutable classes and async services. The same rules lift onto them: say where the state lives, and the tests drive the service through generated action sequences.

      Wallet.csmutable · async
      public record WalletState(int Checking, int Savings);
      
      public class Wallet
      {
          public WalletState State => new(_checking, _savings);
          private int _checking = 30, _savings = 10;
      
          public async Task MoveAsync(int amount)
          {
              await Task.Yield();
              if (amount <= 0 || amount > _checking) return;
              _checking -= amount;
              _savings += amount;
      -       _savings += amount;
      +       _savings += amount - 1;
          }
      }
      
      // say where the state lives; the rules apply to it
      public static readonly ConstraintSet<Wallet> WalletRules =
          ConstraintSet
          .From(("money-is-conserved",
              Constraints<WalletState>.Conserves(s => s.Checking + s.Savings)))
          .Map<Wallet>(w => w.State);
      
      await AutoTest.For(WalletRules, () => new Wallet())
          .MutateAsync<int>("Move", (w, n) => w.MoveAsync(n), _ => Gen.Int[1, 12])
          .Steps(1, 6)
          .RunAsync();
      dotnet test
      sampling action sequences
      Conservation failed.​Rule broken   money-is-conserved did not conserve its value (was 40, became 39).Operation     Move(1)Started from  Wallet { State = WalletState { Checking = 30, Savings = 10 } }Produced      Wallet { State = WalletState { Checking = 29, Savings = 10 } }​Law           No operation creates or destroys a quantity that must be conserved.Consequence   Totals drift: something appears or vanishes between the two halves of a change.​Smallest case found, after 1 shrink. To run this case again: .Seed("7DTWL8nvnax7")

      Conserved across every sampled action sequence.

      The report is real output for this Wallet. The shrink path above it is illustrative.

      start here

      Write the rule once.

      dotnet add package Nullean.Entities
      nuget ↗
      dotnet add package Nullean.Entities.CsCheck
      nuget ↗

      The core has no dependencies. Add CsCheck to a test project for the generated tests.