Motiv is a .NET library for building composable, explainable boolean logic — so you never lose the why behind a true or false.
The boolean type has a problem: once evaluated, you lose all context about why the value is true or false.
This is known as the boolean blindness problem:
// Traditional approach - life before Motiv
if (user.Age >= 18 &&
user.HasValidId &&
(user.Country == "US" || user.HasInternationalPermit) &&
!user.IsRestricted)
{
// Access granted
}
else
{
// Access denied — but which condition failed?
}Motiv addresses this by preserving the structure of boolean expressions, so you can recover the underlying causes when you need them:
// With Motiv
var canAccess = Spec
.From((User user) =>
user.Age >= 18 &
user.HasValidId &
(user.Country == "US" | user.HasInternationalPermit) &
!user.IsRestricted)
.Create("can access");
var result = canAccess.Evaluate(user);
result.Satisfied; // false
result.Assertions; // ["user.Age < 18", "user.HasValidId == false"]Motiv overloads &, |, ^, and ! so the same operators compose propositions and their results.
The short-circuiting && / || are reserved for evaluated results — use .AndAlso() / .OrElse() on propositions.
Notice too that each failing clause is rendered in its own terms — user.Age < 18 for the comparison,
user.HasValidId == false for the boolean — and passing clauses are dropped from the result.
Transform boolean expressions into explanatory logic using the Spec.From() method:
var isEligible = Spec
.From((Customer c) => c.CreditScore > 600 & c.Income > 100000)
.Create("eligible for loan");
var result = isEligible.Evaluate(eligibleCustomer);
result.Satisfied; // true
result.Assertions; // ["c.CreditScore > 600", "c.Income > 100000"]This takes a lambda expression tree (Expression<Func<T, bool>>) and transforms it into a hierarchy of propositions that mirror the expression's logic.
For full control, compose propositions manually — no expression trees:
var hasGoodCredit = Spec
.Build((Customer c) => c.CreditScore > 600)
.Create("good credit");
var hasIncome = Spec
.Build((Customer c) => c.Income > 100000)
.Create("sufficient income");
// create a new proposition
var isEligible = hasGoodCredit.And(hasIncome);
// alternatively, use operator syntax
// var isEligible = hasGoodCredit & hasIncome;
var result = isEligible.Evaluate(eligibleCustomer);
result.Satisfied; // true
result.Assertions; // ["good credit == true", "sufficient income == true"]
// a bare name gets a == true / == false suffix to show the outcomeAdd readable explanations to your logic:
var hasGoodCredit = Spec
.Build((Customer c) => c.CreditScore > 600)
.WhenTrue("has good credit score")
.WhenFalse("credit score too low")
.Create();
var result = hasGoodCredit.Evaluate(eligibleCustomer);
result.Satisfied; // true
result.Assertions; // ["has good credit score"]Supplying an explicit name via Create("name") instead of parameterless Create() changes the semantics: the name plus
a == true/== false suffix becomes the assertion, and the custom strings become metadata, available via Values:
var hasGoodCredit = Spec
.Build((Customer c) => c.CreditScore > 600)
.WhenTrue("has good credit score")
.WhenFalse("credit score too low")
.Create("good credit");
var result = hasGoodCredit.Evaluate(eligibleCustomer);
result.Satisfied; // true
result.Assertions; // ["good credit == true"]
result.Values; // ["has good credit score"]Propositions built from Spec.From() retain a recoverable expression tree,
so they compose into a single predicate that a query provider can translate directly:
var isAdult = Spec.From((Customer c) => c.Age >= 18).Create("is adult");
var isActive = Spec.From((Customer c) => c.IsActive).Create("is active");
var eligible = isAdult & isActive;
// Translate to SQL via any IQueryable provider (e.g. EF Core)
var customers = dbContext.Customers.Where(eligible);
// Or take the raw expression anywhere expressions are accepted
Expression<Func<Customer, bool>> predicate = eligible.ToExpression();Compose rules that touch databases, APIs, or feature flags — with the same explainable results and true short-circuiting of asynchronous work:
var isAdult = Spec
.Build((User u) => u.Age >= 18)
.Create("is adult");
var hasCredit = Spec
.BuildAsync(async (User u, CancellationToken ct) =>
await creditApi.CheckAsync(u.Id, ct))
.WhenTrue("has credit")
.WhenFalse("no credit")
.Create();
var canBuy = isAdult.AndAlso(hasCredit); // credit API never called for minors
var result = await canBuy.EvaluateAsync(user, cancellationToken);
result.Satisfied; // false
result.Assertions; // ["is adult == false"]Async and sync propositions compose freely (sync operands are lifted
automatically), and independent async operands can opt into concurrent
evaluation with AndConcurrently/OrConcurrently/XOrConcurrently.
Attach logging, metrics, or other side-effects without altering a proposition's behavior:
var observed = isEligible
.TapWhenTrue((customer, result) =>
logger.LogInformation("Approved: {Id}", customer.Id))
.TapWhenFalse((customer, result) =>
logger.LogWarning("Denied: {Reason}", result.Reason));
// Use exactly like the original — result, assertions, reason are all unchanged
var result = observed.Evaluate(customer);Every top-level evaluation reports through OpenTelemetry — a span plus counter/histogram metrics — with no Motiv configuration required. Nothing is emitted until your application subscribes:
builder.Services.AddOpenTelemetry()
.WithTracing(tracing => tracing.AddSource(MotivTelemetry.SourceName))
.WithMetrics(metrics => metrics.AddMeter(MotivTelemetry.MeterName));Make assertions about collections of items (also known as higher-order logic):
var allNegative = Spec
.Build((int n) => n < 0)
.AsAllSatisfied()
.WhenTrue("all numbers are negative")
.WhenFalseYield(eval => eval.FalseModels.Select(n => $"{n} is not negative"))
.Create();
var result = allNegative.Evaluate([-1, 2, 3]);
result.Satisfied; // false
result.Assertions; // ["2 is not negative", "3 is not negative"]Install the Motiv NuGet package:
dotnet add package Motivor via the NuGet Package Manager:
Install-Package Motiv- Zero additional dependencies on .NET 8+
- The legacy
netstandard2.0target pulls inSystem.Diagnostics.DiagnosticSourcefor telemetry
- The legacy
- Metadata is evaluated lazily
- Compatible with both .NET and .NET Framework
- Zero-allocation fast paths for boolean-only evaluation
- MIT licensed