Architecture — BaseExpression: The Central Engine
What BaseExpression Is
BaseExpression<TBuilder, T> is the class that makes everything work. No user instantiates this class directly; it is the base class of ValiFlow<T> and ValiFlowQuery<T>. Its responsibilities are:
- Accumulate conditions as the user chains methods
- Manage the logical operator between conditions (AND / OR)
- Build the final expression tree when
Build()is called - Evaluate the tree against concrete instances (
IsValid,Validate) - Manage the builder lifecycle (mutable → frozen → fork)
CRTP: The Trick That Makes Typed Method Chaining Possible
The generic parameter TBuilder in BaseExpression<TBuilder, T> is not arbitrary. It follows the CRTP pattern (Curiously Recurring Template Pattern): the concrete type passes itself as a generic argument to its own base class.
// ValiFlow<T> passes itself as TBuilder
public partial class ValiFlow<T> : BaseExpression<ValiFlow<T>, T>
// ^^^^^^^^^^^
// "myself"
Without CRTP, the problem would be this: BaseExpression needs its methods to return the concrete type of the builder (so that chaining works), but it does not know what that concrete type is.
// Without CRTP: methods return BaseExpression, losing the type
public BaseExpression<TBuilder, T> Or() // returns BaseExpression, not ValiFlow
{
_nextIsAnd = 0;
return this; // caller receives BaseExpression, not ValiFlow
}
// With CRTP: methods return TBuilder (which in practice is ValiFlow<T>)
public TBuilder Or()
{
_nextIsAnd = 0;
return (TBuilder)this; // caller receives ValiFlow<T>
}
The result is that the entire method chain maintains the correct type:
ValiFlow<User> rule = new ValiFlow<User>()
.GreaterThan(u => u.Age, 18) // returns ValiFlow<User>
.Or() // returns ValiFlow<User>
.IsTrue(u => u.IsAdmin); // returns ValiFlow<User>
// ^^^^^^^^^^^^^^^^^^^^^^^^ the compiler always knows it is ValiFlow<User>
Internal State of the Builder
public class BaseExpression<TBuilder, T>
{
// Immutable list of accumulated conditions.
// ImmutableList allows Fork without deep copies.
private volatile ImmutableList<ConditionEntry<T>> _conditions
= ImmutableList<ConditionEntry<T>>.Empty;
// Operator for the NEXT Add: 1=AND (default), 0=OR
// Changes to 0 when the user calls .Or()
// Resets to 1 after being consumed
private int _nextIsAnd = 1;
// Compiled delegate cache (post-freeze)
private Func<T, bool>? _cachedFunc;
private Func<T, bool>? _cachedNegatedFunc;
// Compiled expression cache (post-freeze)
private Expression<Func<T, bool>>? _cachedExpression;
// Lifecycle state: 0=mutable, 1=frozen
// int instead of bool to use Interlocked.CompareExchange
private int _frozen;
// Lock dedicated only to Validate() (condition iteration with delegates)
private readonly object _validateLock = new();
}
Why ImmutableList
ImmutableList<T> allows Fork (cloning a builder) to not require copying all elements. When a fork is created, the new builder receives the same reference to the list. The first mutation in the fork internally creates a new list.
This also allows the builder to be read concurrently (after freeze) without locks.
How AND / OR Works
The AND/OR logic is designed to be intuitive: everything defaults to AND, and OR can be inserted explicitly.
builder
.GreaterThan(u => u.Age, 18) // _nextIsAnd=1 → isAnd=true
.IsTrue(u => u.IsActive) // _nextIsAnd=1 → isAnd=true
.Or() // _nextIsAnd=0
.IsTrue(u => u.IsAdmin); // _nextIsAnd=0 → isAnd=false, then _nextIsAnd=1
Each condition stored in ConditionEntry<T> carries an IsAnd flag that records which operator links it to the previous condition.
The internal list ends up like this:
[
ConditionEntry { Condition = Age > 18, IsAnd = true },
ConditionEntry { Condition = IsActive, IsAnd = true },
ConditionEntry { Condition = IsAdmin, IsAnd = false },
]
The Build() Algorithm
Build() converts the flat list of conditions into a single Expression<Func<T, bool>>. The algorithm has three steps.
Step 1: Group by OR
The list is traversed. Each time a condition with IsAnd = false appears, a new group is started. Conditions with IsAnd = true are added to the current group.
Input: [A(and=T), B(and=T), C(and=F), D(and=T), E(and=F)]
Group 1: [A, B] ← A is first, B has IsAnd=true → same group
Group 2: [C, D] ← C has IsAnd=false → new group; D has IsAnd=true → same group
Group 3: [E] ← E has IsAnd=false → new group
Step 2: AND Within Each Group
Within each group, conditions are combined with Expression.AndAlso (the && of LINQ):
Group 1: A AndAlso B
Group 2: C AndAlso D
Group 3: E
Step 3: OR Between Groups
Groups are combined with each other using Expression.OrElse (the || of LINQ):
(A AndAlso B) OrElse (C AndAlso D) OrElse E
The final result respects standard precedence: AND has higher precedence than OR, just like in C#.
Algorithm Code (simplified)
public Expression<Func<T, bool>> Build()
{
if (_conditions.Count == 0)
return _ => true; // no conditions: everything passes
var parameter = Expression.Parameter(typeof(T), "x");
// Step 1: group by OR
var groups = new List<List<Expression>>();
List<Expression>? currentGroup = null;
foreach (var entry in _conditions)
{
// Replace the original parameter of the expression with the unified parameter
var body = new ParameterReplacer(entry.Condition.Parameters[0], parameter)
.Visit(entry.Condition.Body);
if (!entry.IsAnd || currentGroup == null)
{
currentGroup = new List<Expression>();
groups.Add(currentGroup);
}
currentGroup.Add(body);
}
// Steps 2 and 3: AND within group, OR between groups
Expression? result = null;
foreach (var group in groups)
{
Expression? groupExpr = null;
foreach (var expr in group)
groupExpr = groupExpr == null ? expr : Expression.AndAlso(groupExpr, expr);
result = result == null ? groupExpr : Expression.OrElse(result, groupExpr!);
}
return Expression.Lambda<Func<T, bool>>(result!, parameter);
}
Concrete Example
var rule = new ValiFlow<User>()
.GreaterThan(u => u.Age, 18) // A
.IsTrue(u => u.IsActive) // B
.Or()
.IsTrue(u => u.IsAdmin); // C
// Build() produces:
// x => (x.Age > 18 && x.IsActive) || x.IsAdmin
Why ParameterReplacer Is Needed
Every lambda the user writes has its own ParameterExpression (the u => in u => u.Age > 18). They are distinct objects even though they all represent the same concept ("the T object being evaluated").
When Build() combines multiple conditions into a single tree, all nodes must share exactly the same ParameterExpression object. Otherwise the tree is invalid.
Condition A: parameter_A => parameter_A.Age > 18
Condition B: parameter_B => parameter_B.IsActive
^^^^^^^^^^^ ^^^^^^^^^^^
Are distinct ParameterExpression objects
Build() unifies:
unified_parameter => unified_parameter.Age > 18
&& unified_parameter.IsActive
ParameterReplacer traverses the tree of each condition and replaces the original parameter with the unified parameter. See expression-visitors.md for the implementation details.
BuildNegated
BuildNegated() returns the logical complement: Expression.Not(Build()). Useful for building exclusion filters.
var activeRule = new ValiFlow<User>().IsTrue(u => u.IsActive);
var inactiveFilter = activeRule.BuildNegated();
// Produces: x => !x.IsActive
IsValid vs Validate vs ValidateAll
These three methods evaluate conditions against a concrete instance, but with different levels of detail.
IsValid
The simplest. Compiles the tree to a delegate and executes it. Returns true or false.
bool ok = rule.IsValid(user);
Internally uses the cached delegate (_cachedFunc) to avoid recompiling the tree on each call.
Validate
Evaluates each condition individually and returns a ValidationResult with the list of errors.
var result = rule.Validate(user);
if (!result.IsValid)
{
foreach (var error in result.Errors)
Console.WriteLine($"[{error.ErrorCode}] {error.Message}");
}
To do this, it needs to run each ConditionEntry<T> separately. Each ConditionEntry has its own Lazy<Func<T, bool>> that compiles the delegate for that specific condition.
OR-group short-circuit (v2.0.0): Validate() evaluates OR groups one at a time. The moment a group passes — all of its AND conditions are satisfied — Validate() returns ValidationResult.Ok() without evaluating remaining groups. Error details are collected only for groups that have already failed. This matches the short-circuit semantics of Build().
ValidateAll
Same as Validate, but evaluates all conditions without short-circuiting: even if early conditions fail, it continues evaluating the remaining ones to accumulate all possible errors.
Validate stops at the first failed condition in an AND group. ValidateAll does not stop.
The Freeze/Fork Lifecycle
This is one of the most important designs in the library. It is explained in depth in thread-safety.md, but the essential concept is:
MUTABLE state: the builder is being constructed
conditions can be added, .Or() and .WithMessage() can be called
NOT thread-safe
↓ (first call to IsValid, Build, or Freeze)
FROZEN state: the builder is sealed
any call to mutation methods returns a NEW builder (fork)
the original builder is NEVER modified
IsValid, Build, Validate are thread-safe
Example:
var baseRule = new ValiFlow<User>()
.GreaterThan(u => u.Age, 18);
baseRule.IsValid(user); // <- implicit freeze here
// Now baseRule is frozen.
// Adding conditions does NOT modify baseRule, creates a fork:
var adminRule = baseRule.IsTrue(u => u.IsAdmin); // independent fork
var activeRule = baseRule.IsTrue(u => u.IsActive); // another independent fork
// baseRule is still: only Age > 18
// adminRule is: Age > 18 AND IsAdmin
// activeRule is: Age > 18 AND IsActive
BuildCached
BuildCached() compiles the expression tree to a Func<T, bool> delegate and caches it. After the first compilation, subsequent calls return the cached delegate without recompiling.
Cache publication uses Volatile.Read/Write and Interlocked.CompareExchange to be thread-safe without a lock, following the safe publication pattern.
// Compilation is expensive (~1ms). Caching guarantees it is done only once.
var compiled = rule.BuildCached(); // compiles if no cache exists
bool ok1 = compiled(user1); // direct, without recompiling
bool ok2 = compiled(user2); // direct, without recompiling
Explain
Explain() returns a human-readable representation of the expression tree:
var rule = new ValiFlow<User>()
.GreaterThan(u => u.Age, 18)
.IsTrue(u => u.IsActive);
Console.WriteLine(rule.Explain());
// Output: "(x.Age > 18) AND (x.IsActive == True)"
Useful for debugging and logging. Implemented by ExpressionExplainer in Utils/. See expression-visitors.md.
Generic Type Constraint
public class BaseExpression<TBuilder, T>
where TBuilder : BaseExpression<TBuilder, T>, new()
The constraint where TBuilder : BaseExpression<TBuilder, T>, new() has two parts:
BaseExpression<TBuilder, T>: guarantees CRTP —TBuildermust inherit from the same base class.new(): guarantees thatTBuilderhas a parameterless constructor, needed to create forks inClone().
Without new(), Clone() could not create a new instance of the concrete type without reflection.
CreateNestedBuilder<TProperty>() is virtual (since v2.0.0) with a default implementation of new ValiFlow<TProperty>(). External subclasses of BaseExpression do not need to override it unless they require a different nested builder type. ValiFlowQuery<T> overrides it to throw UnreachableException — its ValidateNested override never delegates to this factory.