FluentContracts

NuGet Version NuGet Downloads License: MIT

Argument validation that reads like the rule it enforces, and fails with a message that says what was expected of which argument. Inspired by FluentAssertions.

dotnet add package FluentContracts

Why another validation library

FluentValidation and Guard from the .NET Community Toolkit are excellent, and if you already use them, keep doing so.

This one exists because guard clauses tend to read as noise: three lines of if and throw for one rule, a nameof to keep in sync, an exception type to pick, and a message to write — or, more often, not write. FluentContracts makes the rule the whole statement, the way FluentAssertions does for a test, and puts the argument name, the exception type and a readable message in for you.

A guard clause, before and after

public void AddOrder(Order myOrder)
{
    if (myOrder == null) throw new ArgumentNullException(nameof(myOrder));
    if (myOrder.Quantity < 5) throw new ArgumentOutOfRangeException(nameof(myOrder), "Quantity cannot be less than 5");
}
public void AddOrder(Order myOrder)
{
    myOrder.Must().NotBeNull().Satisfy<Order>(o => o.Quantity >= 5, "Quantity cannot be less than 5");
}

Must() starts a chain on any value. Every check returns the same chain, so checks follow one another with no glue; .And. between them is optional and purely for reading. The argument's name is captured at the call site, so exceptions point at the right parameter without nameof.

What every check gives you

A failure message that names the argument, the expectation and the value.

port.Must().BeBetween(1, 65535);
// ArgumentOutOfRangeException: Expected port to be between 1 and 65535, but found 70000. (Parameter 'port')

email.Must().BeEmailAddress();
// ArgumentException: Expected email to be a valid email address, but found "not-an-email". (Parameter 'email')

pages.Must().BeInAscendingOrder();
// ArgumentException: Expected pages to be in ascending order, but 5 appears before 3. (Parameter 'pages')

Your own message when you want one. It is always the last parameter, it replaces the default, and it may use {argument} and {value}:

port.Must().BeBetween(1, 65535, "{argument} must be a usable port, got {value}");
// ArgumentOutOfRangeException: port must be a usable port, got 70000 (Parameter 'port')

environment.Must("This should be prod").NotBe("test").NotBeEmpty();
// one message for every check in the chain; a check's own message still wins

The validated value back, so the guard and the read are one expression:

this.port = config.Port.Must().BeBetween(1, 65535).Value();

Value() unwraps a nullable and fails a null argument exactly as NotBeNull would.

The right exception type, without choosing it:

Failure Throws
a null argument ArgumentNullException
a comparison, range, sign or NaN check ArgumentOutOfRangeException
everything else — equality, format, containment, type, your own rules ArgumentException

Or the exception you name: myOrder.Must().NotBeNull<OrderNullException>() and Satisfy<Order, OrderQuantityException>(o => o.Quantity >= 5, "...") throw yours.

Frames you can read. The library hides its own frames from the stack trace, so a failure points at the check you wrote.

What you can check

Every type gets BeNull/NotBeNull, Be/NotBe, BeAnyOf/NotBeAnyOf and Satisfy; then each adds what makes sense for it. The full list is in SupportedContracts.md.

Numbersint, long, short, byte, their unsigned forms, float, double, decimal, and on .NET 8+ any INumber<T> such as Half, Int128 or BigInteger:

quantity.Must().BePositive().BeLessOrEqualTo(100);
retries.Must().BeBetween(1, 5);
page.Must().BeEven();
ratio.Must().BeFinite();          // neither NaN nor infinity

A comparison never passes on NaN and never passes on null; BeNaN and BeFinite are how you ask about NaN on purpose.

Text and characters — shape, format and content:

name.Must().NotBeNullOrWhiteSpace().HaveLengthLessOrEqualTo(64);
code.Must().BeAlphanumeric().BeUppercase();
input.Must().BeEmailAddress();    // also BeUrl, BeIpAddress, BeGuid, BeBase64, BeHexadecimal, BeCreditCardNumber
path.Must().BeExistingFile();
slug.Must().BeMatching("^[a-z0-9-]+$");
title.Must().Contain("draft", StringComparison.OrdinalIgnoreCase);   // Ordinal by default
initial.Must().BeLetter().BeUppercase();

Dates and timesDateTime, DateTimeOffset, TimeSpan, and on .NET 8+ DateOnly and TimeOnly:

start.Must().BeInTheFuture().BeWeekday();
deadline.Must().BeBetween(start, end);
timeout.Must().BeLongerThan(TimeSpan.FromSeconds(1));
stamp.Must().BeUtc();
booking.Must().BeInCurrentYear().NotBeInDecember();

Checks that need the current time take a clock, so tests can pin it: start.Must(dateTimeProvider: clock).BeInTheFuture().

Collections and dictionaries — arrays, IList<T>, IDictionary<TKey, TValue>:

items.Must().NotBeEmpty().HaveCountLessOrEqualTo(100).HaveUniqueItems();
tags.Must().Contain("public").NotContainNull();
scores.Must().BeInDescendingOrder().AllSatisfy(s => s >= 0);
settings.Must().ContainKey("region").NotContainKey("legacy");

BeAnyOf, Contain and ContainAnyOf take one value or one bracketed set: state.Must().BeAnyOf(["draft", "published"], "Not a known state").

Enums, GUIDs, booleans:

role.Must().BeDefined().NotBe(Role.Guest);
flags.Must().HaveFlag(Permissions.Read);
id.Must().NotBeEmpty();
enabled.Must().BeTrue();

Files, directories, streams and URIs:

file.Must().Exist().NotBeEmpty().HaveExtension(".json").HaveSizeLessThan(1_000_000);
folder.Must().Exist().NotBeReadOnly();
stream.Must().BeReadable().BeSeekable();
endpoint.Must().BeAbsolute().BeHttps().HaveHost("api.example.com");

Any object — null, type, and a rule of your own:

payload.Must().NotBeNull().BeOfType<OrderPlaced>();
handler.Must().BeAssignableTo<IHandler>();
myOrder.Must().Satisfy<Order>(o => o.Quantity >= 5);

Your own rules

A rule you check in more than one place is a specification: a predicate and the phrase that completes "Expected {argument} to …". Its failure reads exactly like a built-in check:

static readonly ISpecification<string> ValidIban =
    Spec.From<string>(s => Iban.IsValid(s), "be a valid IBAN");

iban.Must().Satisfy(ValidIban);
// ArgumentException: Expected iban to be a valid IBAN, but found "XX00". (Parameter 'iban')

Rules compose — ValidIban.And(SepaCountry) expects "be a valid IBAN and be in a SEPA country", ValidIban.Not() expects "not be a valid IBAN" — and a rule that needs more room than a lambda derives from Specification<T> and overrides IsSatisfiedBy.

A type of your own can get a contract of its own: derive from ObjectContract<T, TContract>, add checks that return the contract, and add a Must() extension for the type. Every check above then chains with yours.

The package

  • Targets netstandard2.0 and net8.0: .NET Framework 4.6.1+, .NET Core, .NET 5 and later, Mono, Unity.
  • No runtime dependencies.
  • Trimming and Native AOT compatible on net8.0, verified on a trimmed and an AOT-published app.
  • A chain is one small object, and on .NET 10 the JIT keeps it off the heap entirely; see Benchmarks.md.
  • Ships a Roslyn analyzer for the misuses that compile but check the wrong thing (none open at the moment; see the analyzer project).
  • Every public member has XML documentation, so all of the above is in IntelliSense.

The agent skill

The rules that make a chain correct — which check to reach for, where the message goes, which exception a check throws — are documented, but documentation does not reach a coding agent working in your project. So they ship as an agent skill too: fluentcontracts, packaged as a plugin for Claude Code, Codex and Gemini CLI.

With it installed, an agent asked to add argument validation confirms a check exists instead of guessing one, chains from Must() and ends with Value(), keeps the message last, respects the single bracketed-set rule for BeAnyOf and its family, lets the check decide the exception, and reaches for Satisfy or an ISpecification<T> rather than dropping a raw throw into a chain.

It is not in the NuGet package — it is served from this repository, so you install it once per machine and it applies to every project you use FluentContracts in.

Installing it

Claude Code — add this repository as a plugin marketplace, then install the plugin:

/plugin marketplace add FluentContracts/FluentContracts
/plugin install fluentcontracts@fluentcontracts

Pin it to a version by adding the marketplace at a tag instead — FluentContracts/[email protected]. Every merge that moves the plugin version tags it, whether or not a package was released alongside.

Codex — the same repository is a Codex plugin marketplace:

codex plugin marketplace add https://github.com/FluentContracts/FluentContracts
codex plugin install fluentcontracts

Gemini CLI — the repository is a Gemini extension, and the skills are discovered from it:

gemini extensions install https://github.com/FluentContracts/FluentContracts

Any other agent — the skill is a plain folder. Copy skills/fluentcontracts into wherever your harness looks for skills.

Using it

You do not invoke it. It carries a description of when it applies, and the agent loads it on its own once the work is about argument validation — "add guards to this constructor", "replace these if/throw blocks", "validate the options before we use them". Naming FluentContracts in the ask makes it certain.

Two files, both worth reading yourself: SKILL.md is the guidance, and references/cheatsheet.md is the catalogue of what exists plus the rules that decide overloads, messages and exception types.

Help needed 🙏

The goal is for this to be exhaustive, safe and stable enough for production use on large projects, and help is very welcome: a check that is missing, a message that could read better, a platform that is not covered. Open an issue first, then a pull request — CONTRIBUTING.md and AGENTS.md describe the conventions, and the latter is written for coding agents as well as people.

Repository 🚧

Builds

Type Status
Release Release
Code Coverage Coveralls

Pull requests are built and tested on Linux, Windows and macOS by the pr workflow. Every merge into master is a release; CHANGELOG.md is the curated account of each one.

Status

Alt

How to build locally

The .NET SDK version is pinned in global.json. Then:

./build.sh Test          # compile and run the tests (build.cmd on Windows)
./build.sh Test Pack     # ...and produce the package in output/packages

Where to find me 🕵️

Blog X LinkedIn Mastodon Threads BlueSky Linktree Email

Special thanks 🙇‍♂️

Matthias Koch

The creator of NUKE, because I cannot build any .NET project without it and because he helped me tremendously in setting up the repository and everything around this project. (I have also copy-pasted, like his entire build and some markdown files 🤫)

Dennis Doomen

The "FluentAssertions" guy. This whole project was inspired by how that library works and I might have copy-pasted also parts of his repo too 😏

Technology Sponsors 💻

Special thanks to JetBrains for supplying a free license for Rider, which is my primary IDE of choice for this project!

Icon made by IconMonk from Flaticon