Architecture — The Source Generator
The Problem It Solves
ValiFlow<T> uses the Facade + Composition pattern: it has 9 internal components and delegates each call to the corresponding component. This means that for every method of every component, ValiFlow<T> needs a one-line delegation method:
// StringExpression methods (~40 methods):
public ValiFlow<T> MinLength(Expression<Func<T, string?>> s, int min)
=> _stringExpression.MinLength(s, min);
public ValiFlow<T> MaxLength(Expression<Func<T, string?>> s, int max)
=> _stringExpression.MaxLength(s, max);
public ValiFlow<T> ExactLength(Expression<Func<T, string?>> s, int len)
=> _stringExpression.ExactLength(s, len);
// ... 37 more methods just for strings
// NumericExpression methods (~50 methods):
public ValiFlow<T> GreaterThan(Expression<Func<T, int>> s, int val)
=> _numericExpression.GreaterThan(s, val);
// ... 49 more for numerics
// ... and so on for 7 more components
// Total: ~250 delegation methods
That is more than 800 lines of code that:
- Contain no logic
- Are 100% mechanical and repetitive
- Require remembering to add a method to
ValiFlow.cswhenever one is added toIStringExpression - Require updating both the component and the facade whenever a method is renamed
- Must be repeated in
ValiFlowQuery<T>as well
The source generator eliminates this problem entirely.
What a Roslyn Source Generator Is
A Source Generator is a component that runs during compilation. It has full access to the syntax tree and source code symbols (through Roslyn's semantic analysis API). It can generate new .cs files that are added to the project as if the developer had written them.
Unlike T4 templates or external code generation tools, source generators:
- Run on every compilation (always up to date)
- Require no manual build steps
- Have complete visibility into source code (can read interfaces, types, methods)
- Generated code appears in the IDE with IntelliSense
The ForwardInterface Attribute
The activation mechanism is a simple attribute:
[AttributeUsage(AttributeTargets.Field)]
internal sealed class ForwardInterfaceAttribute : Attribute { }
When a field is marked with [ForwardInterface], the generator knows it must generate delegation methods for all methods of that field's interface:
[ForwardInterface]
private readonly IStringExpression<ValiFlow<T>, T> _stringExpression;
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// The generator reads the interface of this field
// and generates one method per interface method
The attribute itself does nothing at runtime. It is only a marker for the generator.
The Generator Pipeline
The generator follows these steps during compilation:
Step 1: Find Marked Fields
Uses Roslyn's incremental API (ForAttributeWithMetadataName) to find all fields with [ForwardInterface] in the assembly being compiled.
Fields found in ValiFlow<T>:
_booleanExpression : IBooleanExpression<ValiFlow<T>, T>
_collectionExpression : ICollectionExpression<ValiFlow<T>, T>
_comparisonExpression : IComparisonExpression<ValiFlow<T>, T>
_stringExpression : IStringExpression<ValiFlow<T>, T>
_numericExpression : INumericExpression<ValiFlow<T>, T>
_dateTimeExpression : IDateTimeExpression<ValiFlow<T>, T>
... etc.
Step 2: Extract Methods from Each Interface
For each field, the generator obtains the interface symbol and calls GetMembers() to get all its methods. It also traverses AllInterfaces to capture methods from parent interfaces (IStringExpression inherits from IStringLengthExpression, IStringContentExpression, etc.).
IStringExpression methods (including base interfaces):
MinLength(selector, int)
MaxLength(selector, int)
ExactLength(selector, int)
LengthBetween(selector, int, int)
StartsWith(selector, string)
EndsWith(selector, string)
Contains(selector, string)
IsEmail(selector)
IsUrl(selector)
... ~40 methods in total
Step 3: Build Data Models
For each method, the generator extracts the information needed to generate the code:
- Method name
- Return type
- List of parameters with types and names
- Generic constraints (if any)
- Name of the field to delegate to
All of this is represented as plain strings (not as Roslyn tree objects), so the generator's incremental engine can easily compare whether something has changed.
Step 4: Detect Conflicts
A problem arises when two different fields define methods with the same name but different generic constraints. For example:
// In IComparisonExpression:
TBuilder EqualTo<TValue>(Expression<Func<T, TValue>> selector, TValue value)
where TValue : IEquatable<TValue>;
// In INumericExpression (IComparableExpression):
TBuilder EqualTo<TValue>(Expression<Func<T, TValue>> selector, TValue value)
where TValue : IComparable<TValue>;
Both methods have the same name and the same parameter types, but different constraints. In C#, you cannot have two public methods with the same signature. The solution is:
- The first method found is generated as
public - Subsequent methods with the same signature are generated as explicit interface implementations:
// Generated: the first is public
public ValiFlow<T> EqualTo<TValue>(Expression<Func<T, TValue>> selector, TValue value)
where TValue : IEquatable<TValue>
=> _comparisonExpression.EqualTo(selector, value);
// Generated: the second is explicit (no access modifier, prefixed with the interface)
ValiFlow<T> INumericExpression<ValiFlow<T>,T>.EqualTo<TValue>(Expression<Func<T, TValue>> selector, TValue value)
where TValue : IComparable<TValue>
=> _numericExpression.EqualTo(selector, value);
Step 5: Generate the File
The generator produces a ValiFlow.Forwarding.g.cs file with all delegation methods:
// <auto-generated/>
// This file was generated by the ForwardingGenerator source generator.
// Do not edit manually.
partial class ValiFlow<T>
{
public ValiFlow<T> MinLength(Expression<Func<T, string?>> selector, int min)
=> _stringExpression.MinLength(selector, min);
public ValiFlow<T> MaxLength(Expression<Func<T, string?>> selector, int max)
=> _stringExpression.MaxLength(selector, max);
// ... ~250 more methods
}
Why the Incremental Generator Is Used
Roslyn offers two APIs for source generators: the old one (ISourceGenerator) and the new incremental one (IIncrementalGenerator). Vali-Flow uses the incremental one because:
- It only regenerates when something relevant changes (not on every full compilation)
- It is more efficient for large projects
ForAttributeWithMetadataNameis optimized to find attributes by name without traversing the entire tree
What Happens When a New Method Is Added to an Interface
- The method is added to the interface (e.g.:
IStringExpression.IsPostalCode) - It is implemented in the component class (e.g.:
StringExpression.IsPostalCode) - On the next compilation, the source generator detects the new method in the interface
- It automatically generates the delegation method in
ValiFlow.Forwarding.g.cs - No changes to
ValiFlow.csorValiFlowQuery.csare needed (unless the method should be excluded from the EF Core variant)
The .g.cs Suffix and the partial Class
Generated files have the .g.cs suffix by convention to distinguish them from manually written code. ValiFlow<T> is declared as partial specifically to allow the generated code to extend the class without modifying the original file:
// ValiFlow.cs (written manually) — defines the constructor and fields
public partial class ValiFlow<T> : BaseExpression<ValiFlow<T>, T>, ...
{
[ForwardInterface]
private readonly IStringExpression<ValiFlow<T>, T> _stringExpression;
// ...
}
// ValiFlow.Forwarding.g.cs (generated) — defines the delegation methods
partial class ValiFlow<T>
{
public ValiFlow<T> MinLength(...) => _stringExpression.MinLength(...);
// ...
}
The C# compiler treats both as a single class.
What the Generator Does NOT Do
The generator only generates delegation (forwarding) methods. It does not generate:
- Business logic (that lives in the components)
- Tests
- XML documentation
- Interface implementations for
ValiFlowQuery<T>(that file has its own[ForwardInterface]annotation)
How to Inspect the Generated Code
In Rider or Visual Studio, generated files are visible in the project explorer under "Generated Files" or "Analyzer Files". The file can also be found in the build folder:
obj/Debug/net8.0/generated/
Vali_Flow.Core.SourceGenerators/
ForwardingGenerator/
ValiFlow.Forwarding.g.cs
ValiFlowQuery.Forwarding.g.cs