Skip to content

Validation System Specification

Functorium’s validation system provides a functional API for Value Object and DTO validation. In the domain layer, TypedValidation and ContextualValidation compose type-safe validation chains. In the application layer, FluentValidationExtensions integrates Value Object validation logic into FluentValidation rules.

TypeNamespaceDescription
TypedValidation<TVO, T>Domains.ValueObjects.Validations.TypedWrapper that carries Value Object type information through chaining
ValidationRules<TVO>Domains.ValueObjects.Validations.TypedValidation entry point that specifies the type parameter once
TypedValidationExtensionsDomains.ValueObjects.Validations.TypedThen* chaining extension methods
ContextualValidation<T>Domains.ValueObjects.Validations.ContextualWrapper that carries context name through chaining
ValidationRulesDomains.ValueObjects.Validations.ContextualFor(contextName) Named Context entry point
ValidationContextDomains.ValueObjects.Validations.ContextualNamed Context validation rule instance methods
ContextualValidationExtensionsDomains.ValueObjects.Validations.ContextualThen* chaining extension methods (Contextual)
IValidationContextDomains.ValueObjects.ValidationsReusable validation context marker for Application Layer
ValidationApplyExtensionsDomains.ValueObjects.ValidationsValidation<Error, T> Tuple Apply (2~5-tuple)
FinApplyExtensionsDomains.ValueObjects.ValidationsFin<T> Tuple Apply (2~5-tuple)
FluentValidationExtensionsApplications.ValidationsFluentValidation + Value Object Validate integration
ConceptDescription
Typed ValidationValidationRules<TVO>.Rule(value) form automatically includes Value Object type in error messages
Contextual ValidationValidationRules.For("Name").Rule(value) form includes string context in error messages
Then* chainingSequential validation chain (stops at first error, Bind-based)
Apply mergingPerforms independent validations in parallel, accumulating all errors
FluentValidation integrationConverts Value Object Validate() results into IRuleBuilder rules

TypedValidation vs ContextualValidation Comparison

Section titled “TypedValidation vs ContextualValidation Comparison”

Both validation approaches provide the same rule catalog but differ in how they identify the error source.

AspectTypedValidationContextualValidation
Entry pointValidationRules<TVO>.Rule(value)ValidationRules.For("ctx").Rule(value)
Error sourcetypeof(TVO).Name (compile-time type)contextName (runtime string)
WrapperTypedValidation<TVO, T>ContextualValidation<T>
Chaining.ThenRule().ThenRule()
Apply2~4-tuple Apply supported2~4-tuple Apply supported
Recommended layerDomain Layer (inside Value Objects)Presentation Layer, rapid prototyping
DomainError factoryDomainError.For<TVO>(...)DomainError.ForContext(...)
// TypedValidation: inside Value Object
public static Validation<Error, ProductName> Validate(string value) =>
ValidationRules<ProductName>.NotEmpty(value)
.ThenMinLength(3)
.ThenMaxLength(100)
.Value;
// ContextualValidation: Named Context
ValidationRules.For("ProductName")
.NotEmpty(name)
.ThenMinLength(3)
.ThenMaxLength(100);

Implementing the IValidationContext marker interface allows defining reusable validation context classes in the Application Layer.

// Application Layer validation context class
public sealed class ProductValidation : IValidationContext;
// Usage: same API as TypedValidation
ValidationRules<ProductValidation>.Positive(amount);
// Error: DomainErrors.ProductValidation.NotPositive

public readonly struct TypedValidation<TValueObject, T>
{
public Validation<Error, T> Value { get; }
// Implicit conversion to Validation<Error, T>
public static implicit operator Validation<Error, T>(
TypedValidation<TValueObject, T> typed) => typed.Value;
}
  • TValueObject: Value Object type (type name included in error messages)
  • T: Type of the value being validated
  • Extract Validation<Error, T> via the Value property or implicit conversion

The ValidationRules<TValueObject> static class provides entry points for validation chains. All methods return TypedValidation<TValueObject, T>.


Entry points (ValidationRules<TVO>):

MethodSignatureDescription
NotNullNotNull<T>(T? value) where T : classReference type null check
NotNullNotNull<T>(T? value) where T : structNullable value type null check

Chaining (TypedValidationExtensions):

MethodSignatureDescription
ThenNotNullThenNotNull<TVO, T>(this TypedValidation<TVO, T?>) where T : classReference type null check
ThenNotNullThenNotNull<TVO, T>(this TypedValidation<TVO, T?>) where T : structNullable value type null check

DomainErrorType: Null()

Entry points:

MethodSignatureDescription
NotEmptyNotEmpty(string value)Whitespace string check (IsNullOrWhiteSpace)
MinLengthMinLength(string value, int minLength)Minimum length
MaxLengthMaxLength(string value, int maxLength)Maximum length
ExactLengthExactLength(string value, int length)Exact length

Chaining:

MethodSignatureDescription
ThenNotEmptyThenNotEmpty<TVO>(this TypedValidation<TVO, string>)Whitespace string check
ThenMinLengthThenMinLength<TVO>(this TypedValidation<TVO, string>, int)Minimum length
ThenMaxLengthThenMaxLength<TVO>(this TypedValidation<TVO, string>, int)Maximum length
ThenExactLengthThenExactLength<TVO>(this TypedValidation<TVO, string>, int)Exact length
ThenNormalizeThenNormalize<TVO>(this TypedValidation<TVO, string>, Func<string, string>)String transformation (normalization)

DomainErrorType: Empty(), TooShort(minLength), TooLong(maxLength), WrongLength(length)

Entry points (where T : notnull, INumber<T>):

MethodSignatureDescription
NotZeroNotZero<T>(T value)Not zero check
NonNegativeNonNegative<T>(T value)Not negative check (>= 0)
PositivePositive<T>(T value)Positive check (> 0)
BetweenBetween<T>(T value, T min, T max)Range check
AtMostAtMost<T>(T value, T max)Maximum value check
AtLeastAtLeast<T>(T value, T min)Minimum value check

Chaining (where T : notnull, INumber<T>):

MethodSignatureDescription
ThenNotZeroThenNotZero<TVO, T>(this TypedValidation<TVO, T>)Not zero check
ThenNonNegativeThenNonNegative<TVO, T>(this TypedValidation<TVO, T>)Not negative check
ThenPositiveThenPositive<TVO, T>(this TypedValidation<TVO, T>)Positive check
ThenBetweenThenBetween<TVO, T>(this TypedValidation<TVO, T>, T min, T max)Range check
ThenAtMostThenAtMost<TVO, T>(this TypedValidation<TVO, T>, T max)Maximum value check
ThenAtLeastThenAtLeast<TVO, T>(this TypedValidation<TVO, T>, T min)Minimum value check

DomainErrorType: Zero(), Negative(), NotPositive(), OutOfRange(min, max), AboveMaximum(max), BelowMinimum(min)

Entry points:

MethodSignatureDescription
MatchesMatches(string value, Regex pattern, string? message = null)Regex pattern match
IsUpperCaseIsUpperCase(string value)Uppercase check
IsLowerCaseIsLowerCase(string value)Lowercase check

Chaining:

MethodSignatureDescription
ThenMatchesThenMatches<TVO>(this TypedValidation<TVO, string>, Regex, string?)Regex pattern match
ThenIsUpperCaseThenIsUpperCase<TVO>(this TypedValidation<TVO, string>)Uppercase check
ThenIsLowerCaseThenIsLowerCase<TVO>(this TypedValidation<TVO, string>)Lowercase check

DomainErrorType: InvalidFormat(pattern), NotUpperCase(), NotLowerCase()

The pattern parameter of ThenMatches is of type Regex. Using [GeneratedRegex] patterns is recommended for performance.

Entry points:

MethodSignatureDescription
NotDefaultNotDefault(DateTime value)Not DateTime.MinValue check
InPastInPast(DateTime value)Past date check
InFutureInFuture(DateTime value)Future date check
BeforeBefore(DateTime value, DateTime boundary)Before boundary date check
AfterAfter(DateTime value, DateTime boundary)After boundary date check
DateBetweenDateBetween(DateTime value, DateTime min, DateTime max)Date range check

Chaining:

MethodSignatureDescription
ThenNotDefaultThenNotDefault<TVO>(this TypedValidation<TVO, DateTime>)Default value check
ThenInPastThenInPast<TVO>(this TypedValidation<TVO, DateTime>)Past date check
ThenInFutureThenInFuture<TVO>(this TypedValidation<TVO, DateTime>)Future date check
ThenBeforeThenBefore<TVO>(this TypedValidation<TVO, DateTime>, DateTime)Before boundary check
ThenAfterThenAfter<TVO>(this TypedValidation<TVO, DateTime>, DateTime)After boundary check
ThenDateBetweenThenDateBetween<TVO>(this TypedValidation<TVO, DateTime>, DateTime, DateTime)Date range check

DomainErrorType: DefaultDate(), NotInPast(), NotInFuture(), TooLate(boundary), TooEarly(boundary), OutOfRange(min, max)

Entry points (where TValue : notnull, IComparable<TValue>):

MethodSignatureDescription
ValidRangeValidRange<TValue>(TValue min, TValue max)min <= max check
ValidStrictRangeValidStrictRange<TValue>(TValue min, TValue max)min < max check (empty range not allowed)

Chaining:

MethodSignatureDescription
ThenValidRangeThenValidRange<TVO, TValue>(this TypedValidation<TVO, (TValue, TValue)>)min <= max check
ThenValidStrictRangeThenValidStrictRange<TVO, TValue>(this TypedValidation<TVO, (TValue, TValue)>)min < max check

Return type is TypedValidation<TVO, (TValue Min, TValue Max)>.

DomainErrorType: RangeInverted(min, max), RangeEmpty(value) (StrictRange only)

Entry points:

MethodSignatureDescription
NotEmptyArrayNotEmptyArray<TElement>(TElement[]? value)Array is not null and not empty check

Chaining:

MethodSignatureDescription
ThenNotEmptyArrayThenNotEmptyArray<TVO, TElement>(this TypedValidation<TVO, TElement[]>)Array not empty check

DomainErrorType: Empty()

Entry points:

MethodSignatureDescription
MustMust<T>(T value, Func<T, bool> predicate, DomainErrorType errorType, string message) where T : notnullUser-defined condition validation

Chaining:

MethodSignatureDescription
ThenMustThenMust<TVO, T>(this TypedValidation<TVO, T>, Func<T, bool>, DomainErrorType, string)User-defined condition
ThenMustThenMust<TVO, T>(this TypedValidation<TVO, T>, Func<T, bool>, DomainErrorType, Func<T, string>)Message factory overload
ValidationRules<Discount>.Must(
rate,
r => r <= 100m,
new DomainErrorType.BusinessRule("MaxDiscount"),
$"Discount rate must not exceed 100%. Current: {rate}%");

TypedValidation supports LINQ query expressions.

MethodDescription
SelectValue transformation (Map)
SelectMany (TypedValidation -> Validation)Chaining via from...in syntax
SelectMany (TypedValidation -> TypedValidation)Chaining within the same TVO type
ToValidationExplicit Validation<Error, T> conversion
// LINQ query expression
from validStart in ValidationRules<DateRange>.NotDefault(startDate)
from validEnd in ValidationRules<DateRange>.NotDefault(endDate)
select (validStart, validEnd);

public readonly struct ContextualValidation<T>
{
public Validation<Error, T> Value { get; }
public string ContextName { get; }
// Implicit conversion to Validation<Error, T>
public static implicit operator Validation<Error, T>(
ContextualValidation<T> contextual) => contextual.Value;
}
public static class ValidationRules
{
public static ValidationContext For(string contextName) => new(contextName);
}

ValidationContext provides the same rule catalog as ValidationRules<TVO> as instance methods. All rule error messages use ContextName instead of typeof(TVO).Name.

Entry point methods provided by ValidationContext. Rules per category are identical to TypedValidation.

CategoryMethods
PresenceNotNull<T>
LengthNotEmpty, MinLength, MaxLength, ExactLength
NumericNotZero<T>, NonNegative<T>, Positive<T>, Between<T>, AtMost<T>, AtLeast<T>
FormatMatches, IsUpperCase, IsLowerCase
DateTimeNotDefault, InPast, InFuture, Before, After, DateBetween
GenericMust<T>

Then* chaining methods provided by ContextualValidationExtensions. The context name is automatically propagated.

CategoryMethods
PresenceThenNotNull<T>
LengthThenNotEmpty, ThenMinLength, ThenMaxLength, ThenExactLength, ThenNormalize
NumericThenNotZero<T>, ThenNonNegative<T>, ThenPositive<T>, ThenBetween<T>, ThenAtMost<T>, ThenAtLeast<T>
// Named Context chaining example
ValidationRules.For("Price")
.Positive(amount)
.ThenAtMost(1_000_000m);
// Named Context Apply example
(ValidationRules.For("Amount").Positive(amount),
ValidationRules.For("Currency").NotEmpty(currency))
.Apply((a, c) => new Money(a, c));

ValidationApplyExtensions provides Apply extension methods for Validation<Error, T> tuples. It solves the issue where LanguageExt’s generic Apply returns K<Validation<Error>, T>, returning a concrete Validation<Error, R> without needing .As() calls.

// Signature (2-tuple example)
public static Validation<Error, R> Apply<T1, T2, R>(
this (Validation<Error, T1> v1, Validation<Error, T2> v2) tuple,
Func<T1, T2, R> f)
Tuple SizeSupport
2-tuple(v1, v2).Apply((a, b) => ...)
3-tuple(v1, v2, v3).Apply((a, b, c) => ...)
4-tuple(v1, v2, v3, v4).Apply((a, b, c, d) => ...)
5-tuple(v1, v2, v3, v4, v5).Apply((a, b, c, d, e) => ...)

TypedValidationExtensions.Apply provides overloads that freely mix TypedValidation and Validation.

Tuple SizeCombination Patterns
2-tupleTT, TV, VT
3-tupleTTT, VVT, TVV, VTV, TTV, TVT, VTT
4-tupleTTTT, TVVV, VTVV, VVTV, VVVT

T = TypedValidation, V = Validation

// TypedValidation + Validation mix
(ValidationRules<Money>.NonNegative(amount),
ValidationRules<Money>.NotEmpty(currency))
.Apply((a, c) => new Money(a, c));

ContextualValidationExtensions.Apply provides overloads with the same pattern for mixing ContextualValidation and Validation. Tuple sizes and combination patterns are identical to TypedValidation Apply (2~4-tuple).

FinApplyExtensions internally converts Fin<T> tuples to Validation<Error, T>, performs Apply, then converts the result back to Fin<R>.

// Signature (2-tuple example)
public static Fin<R> Apply<T1, T2, R>(
this (Fin<T1> v1, Fin<T2> v2) tuple,
Func<T1, T2, R> f)
Tuple SizeSupport
2-tuple(fin1, fin2).Apply((a, b) => ...)
3-tuple(fin1, fin2, fin3).Apply((a, b, c) => ...)
4-tuple(fin1, fin2, fin3, fin4).Apply((a, b, c, d) => ...)
5-tuple(fin1, fin2, fin3, fin4, fin5).Apply((a, b, c, d, e) => ...)
// Fin Apply example
(PersonalName.Create("HyungHo", "Ko"),
EmailAddress.Create("user@example.com"))
.Apply((name, email) => Contact.Create(name, email, now));

FluentValidationExtensions provides extension methods that integrate Value Object Validate() methods into FluentValidation rules. When validation fails, errors implementing the IHasErrorCode interface generate error messages in [ErrorCode] Message format.

Used when the input type and validation result type are the same. C# 14 extension members syntax enables automatic type inference.

public IRuleBuilderOptions<TRequest, TProperty> MustSatisfyValidation(
Func<TProperty, Validation<Error, TProperty>> validationMethod)
RuleFor(x => x.Price)
.MustSatisfyValidation(Money.ValidateAmount);
RuleFor(x => x.Currency)
.MustSatisfyValidation(Money.ValidateCurrency);

Used when the input type and validation result type differ. Only the TValueObject type needs to be specified.

public IRuleBuilderOptions<TRequest, TProperty> MustSatisfyValidationOf<TValueObject>(
Func<TProperty, Validation<Error, TValueObject>> validationMethod)
// string -> Validation<Error, ProductName>
RuleFor(x => x.Name)
.MustSatisfyValidationOf<ProductName>(ProductName.Validate);

When calling methods with additional generic parameters from IRuleBuilderInitial, C# 14 extension members type inference limitations may occur. In this case, use the traditional extension method overload (MustSatisfyValidationOf<TRequest, TProperty, TValueObject>).

String validation for EntityId types implementing IEntityId<TEntityId>. Combines NotEmpty + TryParse into a single rule.

public static IRuleBuilderOptions<TRequest, string> MustBeEntityId<TRequest, TEntityId>(
this IRuleBuilder<TRequest, string> ruleBuilder)
where TEntityId : struct, IEntityId<TEntityId>
RuleFor(x => x.ProductId)
.MustBeEntityId<CreateProductRequest, ProductId>();

Validation for Ardalis.SmartEnum types. Provides three overloads for Value, Name, and string Value.

MethodSignatureDescription
MustBeEnum<TSmartEnum, TValue>IRuleBuilder<TReq, TValue>Validate by Value
MustBeEnum<TSmartEnum> (int)IRuleBuilder<TReq, int>Simplified int Value overload
MustBeEnumName<TSmartEnum, TValue>IRuleBuilder<TReq, string>Validate by Name
MustBeEnumValue<TSmartEnum> (string)IRuleBuilder<TReq, string>Validate by string Value (case-insensitive)
RuleFor(x => x.CurrencyCode)
.MustBeEnumValue<CreateMoneyRequest, Currency>();
RuleFor(x => x.Status)
.MustBeEnum<UpdateOrderRequest, OrderStatus>();

Validates that a value is one of the allowed string values. Case-insensitive, and null or empty strings skip validation.

public static IRuleBuilderOptions<TRequest, string> MustBeOneOf<TRequest>(
this IRuleBuilder<TRequest, string> ruleBuilder,
string[] allowedValues)
RuleFor(x => x.SortBy)
.MustBeOneOf(["name", "price", "date"]);

Validation for Option<TProperty> properties. Skips validation if None, extracts and validates the inner value if Some.

MethodDescription
MustSatisfyValidationInput type == result type
MustSatisfyValidationOf<TValueObject>Input type != result type
// Option<decimal> -> skip if None, validate if Some(100m)
RuleFor(x => x.MinPrice)
.MustSatisfyValidation(Money.Validate);
// Option<string> -> skip if None, validate if Some("name")
RuleFor(x => x.Name)
.MustSatisfyValidationOf<ProductName>(ProductName.Validate);

Validates paired range filters where two Option fields must be provided together, in a single call.

public static void MustBePairedRange<TRequest, T>(
this AbstractValidator<TRequest> validator,
Expression<Func<TRequest, Option<T>>> minExpr,
Expression<Func<TRequest, Option<T>>> maxExpr,
Func<T, Validation<Error, T>> validate,
bool inclusive = false)
where T : IComparable<T>

Validation logic:

  1. Both None — pass (filter not applied)
  2. Only one Some — fail (“MaxPrice is required when MinPrice is specified”)
  3. Both Some — run validate on each + range comparison
// Default: max > min (exclusive)
this.MustBePairedRange(
x => x.MinPrice,
x => x.MaxPrice,
Money.Validate);
// Custom: max >= min (inclusive)
this.MustBePairedRange(
x => x.MinPrice,
x => x.MaxPrice,
Money.Validate,
inclusive: true);

DocumentDescription
Value Object: Enumeration/Validation/Practical PatternsApply merging, chaining patterns, SmartEnum Create guide
Value Object Base ClassesSimpleValueObject<T>, ValueObject, Create patterns
Error System SpecificationDomainErrorType, DomainError.For<T>(), DomainError.ForContext()
Value Object SpecificationValueObject, SimpleValueObject<T>, Union types