FluentContracts
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.
Numbers — int, 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 times — DateTime, 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.0andnet8.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 | |
| Code Coverage |
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
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 🕵️
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!
No comments yet
Be the first to share your take.