Core concepts
Defining rules
Build specification catalogs with stable identities, useful failure metadata, pure predicates, and generated language that stays close to the domain.
A good catalog feels less like a framework surface and more like a small domain vocabulary. Keep the names positive, the predicates pure, and the metadata safe to show in logs or user-facing explanations.
Start with a named leaf
Every leaf requires a stable ID, a display name, and an expression over the candidate type:
public static Spec<Order> Paid =>
Spec.Define<Order>("order.paid", "Paid", order => order.Paid);
The ID is for diagnostics and telemetry; it is not object equality. The name is for people and concise rendering. Neither should be derived from expression text.
Prefer names that describe the positive condition naturally:
| Prefer | Avoid |
|---|---|
Paid |
IsPaidSpecification |
HighPriority |
CheckWhetherPriority |
HasDeliveryAddress |
OrderSatisfiesAddressRule |
CanShip |
ShippingSpecification |
Is is not forbidden, but it usually adds noise once the value is already
clearly Boolean.
Parameterized rules
Arguments belong on catalog methods. Capture the supplied value in the expression rather than reading mutable ambient state:
public static Spec<Order> WorthAtLeast(int minimumCents) =>
Spec.Define<Order>(
"order.worth-at-least",
"Worth at least",
order => order.TotalCents >= minimumCents);
Obtain changing values before constructing the rule. For example, pass a
cutoff into CreatedBefore(cutoff) instead of reading the current clock inside
the predicate. This makes both in-memory evaluation and provider
parameterization predictable.
Name important compositions
A named composition preserves its child tree while presenting a useful domain boundary:
[Expose]
public static Spec<Order> CanShip =>
Paid
.And(HasDeliveryAddress)
.AndNot(Suspended)
.Named(
"order.can-ship",
"Can ship",
"The order is not ready to ship.");
Concise rendering stops at Can ship; diagnostics and translators can still
walk the paid, address, and suspension rules beneath it.
Failure messages should describe the failed condition without exposing candidate values. Optional codes and paths are useful for machines; explicit context is snapshotted and should contain only deliberately non-sensitive data.
Choose what becomes a domain property
[Expose] generates a Boolean extension property for an argument-free rule.
Use it for domain concepts that genuinely improve a call site, such as
order.CanShip. Parameterized rules cannot become properties because their
arguments would have nowhere to go.
The generator caches zero-argument rules as stable definitions. Parameterized rules are constructed per invocation, so argument values are never used as a global cache key.
Catalog shapes are intentionally strict
A catalog must be a top-level, non-generic, static partial class. Rules may be
public static get-only properties or public static readonly fields. Mutable
fields, settable properties, generic rule methods, and ref or out
parameters are compile-time errors rather than members that quietly disappear:
[Fact]
public void Unsupported_rule_shapes_are_reported_instead_of_silently_disappearing()
{
const string source = """
using FluentSpecifications;
public sealed class Order;
[SpecificationSet<Order>]
public static partial class OrderRules
{
public static Spec<Order> Mutable =
Spec.Define<Order>("order.mutable", "Mutable", _ => true);
public static Spec<Order> Settable { get; set; } =
Spec.Define<Order>("order.settable", "Settable", _ => true);
public static Spec<Order> Generic<T>(T value) =>
Spec.Define<Order>("order.generic", "Generic", _ => value != null);
public static Spec<Order> WithOutput(out int value)
{
value = 1;
return Spec.Define<Order>("order.output", "Output", _ => true);
}
}
""";
var result = Run(source);
var diagnostics = result.Diagnostics
.Where(item => item.Id == "FSPEC004")
.ToArray();
Assert.Equal(4, diagnostics.Length);
Assert.All(diagnostics, diagnostic =>
Assert.Equal(DiagnosticSeverity.Error, diagnostic.Severity));
}
The source generator also diagnoses invalid catalogs, exposed names that hide instance members, and projects that do not use C# 14.
Keep predicates boring
Specification predicates should be deterministic and free of I/O. Avoid:
- mutable global or closure state;
- clocks read from inside the expression;
- network, filesystem, or database calls;
- logging or mutation as a side effect; and
- domain methods unless every target provider is known to translate them.
Async rules are a separate abstraction. A synchronous Boolean expression is what makes one rule tree usable for memory, diagnostics, and provider translation.