Ave.Extensions.FluentContracts
0.0.1-preview.2
dotnet add package Ave.Extensions.FluentContracts --version 0.0.1-preview.2
NuGet\Install-Package Ave.Extensions.FluentContracts -Version 0.0.1-preview.2
<PackageReference Include="Ave.Extensions.FluentContracts" Version="0.0.1-preview.2" />
<PackageVersion Include="Ave.Extensions.FluentContracts" Version="0.0.1-preview.2" />
<PackageReference Include="Ave.Extensions.FluentContracts" />
paket add Ave.Extensions.FluentContracts --version 0.0.1-preview.2
#r "nuget: Ave.Extensions.FluentContracts, 0.0.1-preview.2"
#:package Ave.Extensions.FluentContracts@0.0.1-preview.2
#addin nuget:?package=Ave.Extensions.FluentContracts&version=0.0.1-preview.2&prerelease
#tool nuget:?package=Ave.Extensions.FluentContracts&version=0.0.1-preview.2&prerelease
Ave.Extensions.FluentContracts
A fluent contract library for .NET with dual API: Must() for tests (throws on failure) and Should() for validation (returns Result<T, Error>).
Why Must() and Should()?
The naming follows RFC 2119 (IETF standard for requirement levels in specifications):
| Keyword | RFC 2119 Meaning | This Library |
|---|---|---|
| MUST | "the definition is an absolute requirement" | Throws on violation |
| SHOULD | "there may exist valid reasons in particular circumstances to ignore" | Returns Result, caller decides |
Note: FluentAssertions uses .Should() for throwing assertions, which is semantically inverted per RFC 2119. This library aligns with the original RFC 2119 semantics.
Packages
| Package | Purpose | Use In |
|---|---|---|
Ave.Extensions.FluentContracts |
Core conditions and contract engine | Both |
Ave.Extensions.FluentContracts.ForValidation |
Should() entry points |
Production code |
Ave.Extensions.FluentContracts.ForTesting |
Must() entry points |
Test projects |
Acknowledgements
This library is inspired by FluentAssertions, the excellent and widely-used assertion library for .NET. The fluent API design, method naming conventions, and many assertion patterns are derived from FluentAssertions.
Installation
# For production validation code
dotnet add package Ave.Extensions.FluentContracts.ForValidation
# For test projects
dotnet add package Ave.Extensions.FluentContracts.ForTesting
Two APIs: Must() vs Should()
Must() - For Tests (Throws on Failure)
Use Must() in your tests. It auto-detects your test framework (xUnit, NUnit, MSTest, TUnit) and throws the appropriate assertion exception on failure.
using Ave.Extensions.FluentContracts.ForTesting;
[Fact]
public void Name_should_start_with_capital()
{
string name = GetName();
name.Must()
.NotBeNullOrEmpty()
.And.StartWith("A");
// Throws XunitException / NUnit.AssertionException / MSTestAssertFailedException
// Auto-detected, no configuration needed
}
Should() - For Validation (Returns Result)
Use Should() in production code for validation. Returns Result<T, Error> instead of throwing.
using Ave.Extensions.FluentContracts.ForValidation;
using Ave.Extensions.Functional;
Result<string?, Error> result = input.Should()
.NotBeNullOrEmpty()
.And.Contain("@");
if (result.IsFailure)
{
Console.WriteLine(result.Error.Message);
Console.WriteLine(result.Error.Code); // e.g., "Contract/String/Contain"
}
Basic Usage Examples
In Tests with Must()
using Ave.Extensions.FluentContracts.ForTesting;
// String contracts
"hello world".Must()
.NotBeNullOrEmpty()
.And.StartWith("hello")
.And.Contain("world");
// Numeric contracts
42.Must()
.BePositive()
.And.BeGreaterThan(0)
.And.BeLessThan(100);
// Collection contracts
new[] { 1, 2, 3 }.Must()
.HaveCount(3)
.And.Contain(2);
// Boolean contracts
true.Must().BeTrue();
// Object contracts
myObject.Must()
.NotBeNull()
.And.BeOfType<MyClass>();
In Production with Should()
using Ave.Extensions.FluentContracts.ForValidation;
public Result<User, Error> ValidateUser(User user)
{
Result<string?, Error> nameResult = user.Name.Should()
.NotBeNullOrEmpty()
.And.HaveLengthGreaterThan(2);
if (nameResult.IsFailure)
return Result<User, Error>.Failure(nameResult.Error);
Result<int, Error> ageResult = user.Age.Should()
.BeGreaterThanOrEqualTo(0)
.And.BeLessThan(150);
if (ageResult.IsFailure)
return Result<User, Error>.Failure(ageResult.Error);
return Result<User, Error>.Success(user);
}
Available Contract Types
Primitive Types
- BooleanContracts -
BeTrue(),BeFalse(),Be(),NotBe(),Imply() - NumericContracts -
Be(),NotBe(),BePositive(),BeNegative(),BeGreaterThan(),BeLessThan(),BeInRange(),BeOneOf() - StringContracts -
Be(),NotBe(),BeEmpty(),NotBeEmpty(),Contain(),StartWith(),EndWith(),MatchRegex(),HaveLength() - GuidContracts -
Be(),NotBe(),BeEmpty(),NotBeEmpty()
Nullable Types
- NullableBooleanContracts -
HaveValue(),NotHaveValue(),BeNull(),NotBeNull(), plus all boolean contracts - NullableNumericContracts -
HaveValue(),NotHaveValue(), plus all numeric contracts
Date and Time
- DateTimeContracts -
Be(),NotBe(),BeBefore(),BeAfter(),BeOnOrBefore(),BeOnOrAfter(),BeCloseTo(),HaveYear(),HaveMonth(),HaveDay() - DateTimeOffsetContracts - Same as DateTime plus
HaveOffset(),BeSameDateAs() - TimeSpanContracts -
Be(),NotBe(),BePositive(),BeNegative(),BeGreaterThan(),BeLessThan(),BeCloseTo()
Collections
- CollectionContracts -
BeEmpty(),NotBeEmpty(),HaveCount(),Contain(),NotContain(),ContainSingle(),OnlyContain(),BeSubsetOf(),BeInAscendingOrder(),BeInDescendingOrder() - StringCollectionContracts - All collection contracts plus
ContainMatch(),AllMatch(),ContainEquivalentOf(),NotContainNullOrEmpty() - DictionaryContracts -
BeEmpty(),NotBeEmpty(),HaveCount(),ContainKey(),ContainValue(),ContainKeyValuePair(),ContainKeys()
Other Types
- ObjectContracts -
Be(),NotBe(),BeNull(),NotBeNull(),BeSameAs(),NotBeSameAs(),BeOfType(),BeAssignableTo() - TypeContracts -
Be(),NotBe(),BeAssignableTo(),NotBeAssignableTo(),Implement(),HaveAttribute() - EnumContracts -
Be(),NotBe(),HaveFlag(),NotHaveFlag(),BeDefined(),BeNull(),NotBeNull() - ComparableContracts -
Be(),NotBe(),BeGreaterThan(),BeLessThan(),BeInRange(),BeOneOf(),BeRankedEquallyTo()
Exceptions and Functions
- ActionContracts -
Throw<T>(),ThrowExactly<T>(),NotThrow(),NotThrow<T>() - ExceptionContracts -
WithMessage(),WithInnerException<T>(),Where() - FunctionContracts -
Return(),NotReturn(),ReturnNotNull(),ReturnNull(),Satisfy(),Throw<T>(),NotThrow()
XML
- XDocumentContracts -
HaveRoot(),HaveElement() - XElementContracts -
HaveAttribute(),HaveValue(),HaveElement() - XAttributeContracts -
HaveValue()
Chaining Contracts
Both Must() and Should() support fluent chaining with the And property:
// With Must() - throws on first failure
"hello world".Must()
.NotBeNullOrEmpty()
.And.StartWith("hello")
.And.EndWith("world")
.And.Contain(" ");
// With Should() - returns Result with first failure
Result<string?, Error> result = "hello world".Should()
.NotBeNullOrEmpty()
.And.StartWith("hello")
.And.EndWith("world");
Error Codes
Each contract failure produces a structured error code following the pattern Contract/{Type}/{Method}:
var result = "hello".Should().StartWith("world");
// result.Error.Code == "Contract/String/StartWith"
Exception Testing
// Test that an action throws (with Must)
Action action = () => throw new ArgumentNullException("param");
action.Must().Throw<ArgumentNullException>();
// Test that an action throws (with Should)
Result<ArgumentNullException?, Error> result = action.Should()
.Throw<ArgumentNullException>()
.WithMessage("*param*");
// Test that code doesn't throw
Action safeAction = () => Console.WriteLine("safe");
safeAction.Must().NotThrow();
Test Framework Auto-Detection
Must() automatically detects your test framework at runtime and throws the appropriate exception type:
| Test Framework | Exception Type |
|---|---|
| xUnit | Xunit.Sdk.XunitException |
| NUnit | NUnit.Framework.AssertionException |
| MSTest | Microsoft.VisualStudio.TestTools.UnitTesting.AssertFailedException |
| TUnit | TUnit.Assertions.Exceptions.AssertionException |
| None | ContractViolationException (fallback) |
No configuration or adapter packages needed. Detection runs once on first failure and caches the result.
Integration with Functional Extensions
Since Should() returns Result<T, Error>, it integrates seamlessly with Ave.Extensions.Functional:
Chaining with OnSuccessBind
using Ave.Extensions.FluentContracts.ForValidation;
using Ave.Extensions.Functional;
// Chain validations - each step only runs if previous succeeded
Result<int, Error> result = "42".Should()
.NotBeNullOrEmpty()
.OnSuccessBind(s => s.Should().MatchRegex(@"^\d+$"))
.OnSuccessBind(s => int.Parse(s).Should().BePositive());
// result.Value == 42
// Validate related fields
Result<Order, Error> ValidateOrder(Order order)
{
return order.CustomerId.Should()
.NotBeNullOrEmpty()
.OnSuccessBind(_ => order.Total.Should().BeGreaterThan(0))
.OnSuccessBind(_ => order.Items.Should().NotBeEmpty())
.OnSuccessMap(_ => order);
}
Transforming with OnSuccessMap
// Transform validated values
Result<int, Error> result = "42".Should()
.NotBeNullOrEmpty()
.OnSuccessMap(int.Parse)
.OnSuccessBind(n => n.Should().BePositive())
.OnSuccessMap(n => n * 2);
// result.Value == 84
Validation Pipelines
public Result<User, Error> ValidateAndCreateUser(string name, string email, int age)
{
return name.Should()
.NotBeNullOrEmpty()
.And.HaveLengthGreaterThan(2)
.OnSuccessBind(_ => email.Should()
.NotBeNullOrEmpty()
.And.Contain("@"))
.OnSuccessBind(_ => age.Should()
.BeGreaterThanOrEqualTo(18)
.And.BeLessThan(120))
.OnSuccessMap(_ => new User(name, email, age));
}
Error Handling with Match
string message = input.Should()
.NotBeNullOrEmpty()
.And.StartWith("https://")
.Match(
onSuccess: url => $"Valid URL: {url}",
onError: error => $"Invalid: {error.Message}");
Collecting Multiple Errors
public Result<Registration, Error[]> ValidateRegistration(Registration reg)
{
var errors = new List<Error>();
reg.Username.Should()
.NotBeNullOrEmpty()
.And.HaveLengthBetween(3, 20)
.OnFailureDo(e => errors.Add(e));
reg.Email.Should()
.NotBeNullOrEmpty()
.And.Contain("@")
.OnFailureDo(e => errors.Add(e));
reg.Age.Should()
.BeGreaterThanOrEqualTo(18)
.OnFailureDo(e => errors.Add(e));
return errors.Count > 0
? Result<Registration, Error[]>.Failure(errors.ToArray())
: Result<Registration, Error[]>.Success(reg);
}
Extensibility
The library supports two extensibility patterns: adding custom contracts to existing types, and creating entirely new API entry points.
Adding Custom Contracts
Extend the base classes to add contracts that work with both Should() and Must():
using Ave.Extensions.FluentContracts;
using Ave.Extensions.ErrorPaths;
namespace Acme.Contracts;
public static class UrlContractCodes
{
public static readonly ErrorCode BeAbsoluteUri = ErrorCode.Parse("Contract/Url/BeAbsoluteUri");
public static readonly ErrorCode UseHttps = ErrorCode.Parse("Contract/Url/UseHttps");
}
public static class UrlStringContracts
{
public static TSelf BeAbsoluteUri<TSelf>(this StringContractsBase<TSelf> contracts)
where TSelf : StringContractsBase<TSelf>
{
return contracts.Assert(
s => Uri.TryCreate(s, UriKind.Absolute, out _),
UrlContractCodes.BeAbsoluteUri,
$"Expected absolute URI but found '{contracts.Subject ?? "(null)"}'");
}
public static TSelf UseHttps<TSelf>(this StringContractsBase<TSelf> contracts)
where TSelf : StringContractsBase<TSelf>
{
return contracts.Assert(
s => Uri.TryCreate(s, UriKind.Absolute, out var uri) &&
uri.Scheme == Uri.UriSchemeHttps,
UrlContractCodes.UseHttps,
$"Expected HTTPS URL but found '{contracts.Subject ?? "(null)"}'");
}
}
// Works with both APIs
callbackUrl.Must().BeAbsoluteUri().And.UseHttps();
Result<string?, Error> result = callbackUrl.Should().BeAbsoluteUri().And.UseHttps();
Creating a Guard API
Create an Ensure() API for constructor guard clauses that throws ArgumentException:
using Ave.Extensions.FluentContracts;
namespace Acme.Contracts;
public sealed class EnsureStringContracts : StringContractsBase<EnsureStringContracts>
{
private readonly string _paramName;
public EnsureStringContracts(string? subject, string paramName)
: base(subject, throwOnFailure: false)
{
_paramName = paramName;
}
public string Value => HasFailure
? throw new ArgumentException($"Parameter validation failed for '{_paramName}'", _paramName)
: Subject!;
}
public static class EnsureExtensions
{
public static EnsureStringContracts Ensure(this string? subject, string paramName)
=> new EnsureStringContracts(subject, paramName);
}
public class User
{
public string Email { get; }
public string Name { get; }
public User(string email, string name)
{
Email = email.Ensure(nameof(email)).NotBeNullOrEmpty().And.Contain("@").Value;
Name = name.Ensure(nameof(name)).NotBeNullOrEmpty().And.HaveLength(50).Value;
}
}
Domain-Specific Contracts: Money
using Ave.Extensions.FluentContracts;
using Ave.Extensions.ErrorPaths;
using Ave.Extensions.Functional;
namespace Acme.Contracts;
public readonly record struct Money(decimal Amount, string Currency);
public static class MoneyContractCodes
{
public static readonly ErrorCode BePositive = ErrorCode.Parse("Contract/Money/BePositive");
public static readonly ErrorCode NotExceed = ErrorCode.Parse("Contract/Money/NotExceed");
public static readonly ErrorCode BeCurrency = ErrorCode.Parse("Contract/Money/BeCurrency");
}
public abstract class MoneyContractsBase<TSelf> : Contracts<Money, TSelf>
where TSelf : MoneyContractsBase<TSelf>
{
protected MoneyContractsBase(Money subject, bool throwOnFailure)
: base(subject, throwOnFailure) { }
public TSelf BePositive()
{
return Assert(
m => m.Amount > 0,
MoneyContractCodes.BePositive,
$"Expected positive amount but found {Subject.Amount} {Subject.Currency}");
}
public TSelf NotExceed(decimal maxAmount)
{
return Assert(
m => m.Amount <= maxAmount,
MoneyContractCodes.NotExceed,
$"Expected amount not exceeding {maxAmount} but found {Subject.Amount} {Subject.Currency}");
}
public TSelf BeCurrency(string expectedCurrency)
{
return Assert(
m => m.Currency.Equals(expectedCurrency, StringComparison.OrdinalIgnoreCase),
MoneyContractCodes.BeCurrency,
$"Expected currency {expectedCurrency} but found {Subject.Currency}");
}
}
public sealed class MoneyContracts : MoneyContractsBase<MoneyContracts>
{
public MoneyContracts(Money subject) : base(subject, throwOnFailure: false) { }
public static implicit operator Result<Money, Error>(MoneyContracts a) => a.ToResult();
}
public sealed class MustMoneyContracts : MoneyContractsBase<MustMoneyContracts>
{
public MustMoneyContracts(Money subject) : base(subject, throwOnFailure: true) { }
}
public static class MoneyContractExtensions
{
public static MoneyContracts Should(this Money subject) => new(subject);
public static MustMoneyContracts Must(this Money subject) => new(subject);
}
// In payment processing
public Result<Payment, Error> ProcessPayment(Money amount)
{
return amount.Should()
.BePositive()
.And.BeCurrency("EUR")
.And.NotExceed(10000m)
.OnSuccessMap(_ => CreatePayment(amount));
}
// In tests
payment.Amount.Must().BePositive().And.BeCurrency("EUR");
Domain-Specific Contracts: Date Ranges
public readonly record struct DateRange(DateOnly Start, DateOnly End);
public static class DateRangeContractCodes
{
public static readonly ErrorCode BeValid = ErrorCode.Parse("Contract/DateRange/BeValid");
public static readonly ErrorCode NotExceedDays = ErrorCode.Parse("Contract/DateRange/NotExceedDays");
public static readonly ErrorCode BeInFuture = ErrorCode.Parse("Contract/DateRange/BeInFuture");
}
public abstract class DateRangeContractsBase<TSelf> : Contracts<DateRange, TSelf>
where TSelf : DateRangeContractsBase<TSelf>
{
protected DateRangeContractsBase(DateRange subject, bool throwOnFailure)
: base(subject, throwOnFailure) { }
public TSelf BeValid()
{
return Assert(
r => r.Start <= r.End,
DateRangeContractCodes.BeValid,
$"Expected valid date range but Start ({Subject.Start}) is after End ({Subject.End})");
}
public TSelf NotExceedDays(int maxDays)
{
return Assert(
r => (r.End.ToDateTime(TimeOnly.MinValue) - r.Start.ToDateTime(TimeOnly.MinValue)).Days <= maxDays,
DateRangeContractCodes.NotExceedDays,
$"Expected range not exceeding {maxDays} days");
}
public TSelf StartInFuture()
{
return Assert(
r => r.Start > DateOnly.FromDateTime(DateTime.Today),
DateRangeContractCodes.BeInFuture,
$"Expected start date in the future but found {Subject.Start}");
}
}
public sealed class DateRangeContracts : DateRangeContractsBase<DateRangeContracts>
{
public DateRangeContracts(DateRange subject) : base(subject, throwOnFailure: false) { }
public static implicit operator Result<DateRange, Error>(DateRangeContracts a) => a.ToResult();
}
public sealed class MustDateRangeContracts : DateRangeContractsBase<MustDateRangeContracts>
{
public MustDateRangeContracts(DateRange subject) : base(subject, throwOnFailure: true) { }
}
public static class DateRangeContractExtensions
{
public static DateRangeContracts Should(this DateRange subject) => new(subject);
public static MustDateRangeContracts Must(this DateRange subject) => new(subject);
}
// Booking validation
public Result<Booking, Error> CreateBooking(DateRange period, Room room)
{
return period.Should()
.BeValid()
.And.StartInFuture()
.And.NotExceedDays(30)
.OnSuccessBind(_ => room.Should().BeAvailable(period))
.OnSuccessMap(_ => new Booking(period, room));
}
License
MIT License
| Product | Versions 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 was computed. 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 was computed. 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 was computed. 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. |
-
.NETStandard 2.0
- Ave.Extensions.ErrorPaths (>= 0.0.2-preview.1)
- Ave.Extensions.Functional.Core (>= 0.0.5-preview.1)
NuGet packages (2)
Showing the top 2 NuGet packages that depend on Ave.Extensions.FluentContracts:
| Package | Downloads |
|---|---|
|
Ave.Extensions.FluentContracts.ForTesting
Must() entry points for Ave.Extensions.FluentContracts. Throws framework-specific exceptions on failure (xUnit, NUnit, MSTest, TUnit). Inspired by Design by Contract and RFC 2119. |
|
|
Ave.Extensions.FluentContracts.ForValidation
Should() entry points for Ave.Extensions.FluentContracts. Returns Result<T, Error> on failure for production validation. Inspired by Design by Contract and RFC 2119. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.0.1-preview.2 | 92 | 2/15/2026 |