Core concepts
Composition
Combine specifications with readable connector properties, explicit grouping, word-based negation, parameterized rules, constants, and aggregate laws.
Composition always returns another Spec<T>. It never collapses into a
delegate or expression that has forgotten its named children.
Zero-argument rules stay terse
Generated connector properties remove parentheses when the right-hand rule is already named by the catalog:
[Fact]
public void Zero_argument_rules_compose_without_parentheses()
{
var rule = CanShip.And.HighPriority.AndNot.Suspended;
Assert.True(rule.Matches(new Order(
Paid: true,
HasAddress: true,
HighPriority: true)));
Assert.False(rule.Matches(new Order(
Paid: true,
HasAddress: true,
HighPriority: true,
Suspended: true)));
}
The connectors use words rather than overloaded &, |, or ! operators:
| Syntax | Meaning |
|---|---|
a.And.b |
both rules must pass |
a.Or.b |
either rule may pass |
a.AndNot.b |
a passes and b does not |
a.OrNot.b |
a passes or b does not |
a.Not |
invert a |
Parentheses mean something
Invoke a connector when the right side is grouped or computed dynamically:
[Fact]
public void Connector_invocation_supports_explicit_grouping()
{
var rule = CanShip.And(HighPriority.Or.ManualOverride);
Assert.True(rule.Matches(new Order(
Paid: true,
HasAddress: true,
ManualOverride: true)));
Assert.False(rule.Matches(new Order(Paid: true, HasAddress: true)));
}
This makes CanShip.And(HighPriority.Or.ManualOverride) visibly different from
a flat chain. Rendering preserves the same grouping.
Arguments keep their parentheses
Parameterized rules remain methods, but the connective itself stays terse:
[Fact]
public void Parameterized_rules_keep_parentheses_only_for_arguments()
{
var rule = CanShip.And.WorthAtLeast(100m);
Assert.True(rule.Matches(new Order(Paid: true, HasAddress: true, Total: 150m)));
Assert.False(rule.Matches(new Order(Paid: true, HasAddress: true, Total: 50m)));
}
The generated surface preserves overloads, params, optional arguments, and
escaped C# keywords:
[Fact]
public void Params_overloads_and_keyword_parameter_names_survive_generation()
{
var order = new Order(
Paid: true,
HasAddress: true,
Total: 150m,
Region: "AU");
var rule = CanShip
.And.InAnyRegion("AU", "NZ")
.And.RegionNamed(@class: "AU")
.And.WorthAtLeast(100L);
Assert.True(rule.Matches(order));
}
These awkward signatures are covered because source-generator bugs tend to hide in precisely this kind of ordinary C# detail.
Negation uses words
Use Suspended.Not for a standalone negation, or AndNot and OrNot inside a
chain. Double negation preserves behavior. A failed unnamed negation uses a
neutral diagnostic message rather than reusing the positive rule’s failure
text as though it described the opposite condition.
Constants and empty aggregates
Always<T>() and Never<T>() make the absence of a restriction explicit.
AllOf and AnyOf take immutable snapshots of their input and enumerate it
exactly once.
The empty cases follow Boolean identity laws:
[Fact]
public void Empty_aggregates_follow_boolean_identity_laws()
{
Assert.True(Spec.AllOf(Array.Empty<Spec<Order>>()).Matches(new Order()));
Assert.False(Spec.AnyOf(Array.Empty<Spec<Order>>()).Matches(new Order()));
}
An empty disjunction does not mean “return everything.” Use Always<T>() when
the caller intentionally supplies no restriction.
Evaluation order
Matches evaluates left to right and short-circuits just like normal Boolean
code:
[Fact]
public void Matches_short_circuits_and_from_left_to_right()
{
var probe = new EvaluationProbe();
var left = Rule("left", _ => false);
var right = Rule("right", _ => probe.Return(true));
Assert.False(left.And(right).Matches(new Order()));
Assert.False(probe.WasEvaluated);
}
That behavior is suitable for a fast yes-or-no decision. Diagnostic evaluation has a different default because an explanation often needs to inspect every relevant branch.