Onboarding guide
6. Validation
What this group covers. This is the framework-level validation kit that
MMCA.Common.Application ships so every module validates input the same way. It has four parts:
(1) a set of composable FluentValidation rule sets, eleven general-purpose ones in
CommonValidationRules.cs (RequiredStringRules<T>,
OptionalStringRules<T>, EmailRules<T>,
AbsoluteUrlRules<T>, PositiveIntRules<T>,
PositiveDecimalRules<T>, NonNegativeIntRules<T>,
RequiredIdRules<T, TId>,
OptionalPositiveIdRules<T, TId>,
PasswordRules<T>, StrongPasswordRules<T>) plus the
six address-field rules (AddressLine1Rules<T>,
AddressLine2Rules<T>, CityRules<T>,
StateRules<T>, ZipCodeRules<T>,
CountryRules<T>) and the AddressValidator that assembles
them; (2) the shared helper that gives every one of those rules an optional machine-readable code,
OptionalErrorCodeExtensions; (3) one convention validator,
CommandRequestValidator<TCommand, TRequest>, that
bridges a command to its request's validators; and (4) one failure-mapping extension,
ValidationFailureExtensions, that turns FluentValidation output
into domain Error values. Riding along in the same
namespace family is CurrentUserServiceExtensions, the caller
guard that answers the other pre-execution question a handler asks ("is there an authenticated user
at all?"). The per-feature validators that consume all of this (ADC's SessionEventIdRules<T>,
Store's ProductCategoryIdRules<T>, Identity's RegisterRequestValidator) live in their module
chapters. The external library underneath is FluentValidation 12
(primer §3); nothing here hand-rolls
validation.
Where validation sits in the request lifecycle. Validation is not invoked by handlers. It is a
cross-cutting stage of the CQRS pipeline
(primer §2), governed by
ADR-014. The shipped command
chain runs FeatureGate -> Authorization -> Logging -> Caching -> Validating -> Timeout ->
Transactional -> handler, and the query chain is the same minus the transaction. Two decorators feed
on this chapter's types: ValidatingCommandDecorator<TCommand, TResult>
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingCommandDecorator.cs:31)
and ValidatingQueryDecorator<TQuery, TResult>
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingQueryDecorator.cs:35),
the second added by the ADR's 2026-08-26 revision so that a query carrying paging or filter input is
gated the same way a command is. Placement is the point: the Validating stage sits before
TransactionalCommandDecorator<TCommand, TResult>,
so an invalid command short-circuits before a database transaction is ever opened
(ValidatingCommandDecorator.cs:24-27). [Rubric §6, CQRS & Event-Driven] assesses whether reads
and writes are separated and whether cross-cutting behavior lives in the pipeline rather than inside
use cases: validation here is one decorator, not an if (!valid) return prologue copied into every
handler. [Rubric §12, Performance & Scalability] and [Rubric §24, Forms, Validation & UX Safety]
apply for the same reason: one gate, uniform for REST, gRPC, and event-consumer entry points alike.
Every registered validator runs, and they run sequentially. Both decorators materialize their
injected IEnumerable<IValidator<T>> into an array once
(ValidatingCommandDecorator.cs:37, ValidatingQueryDecorator.cs:40) and, when that array is empty,
call the inner handler untouched (ValidatingCommandDecorator.cs:65, ValidatingQueryDecorator.cs:69),
so a use case with no validator costs nothing. When validators exist, the decorator loops over all
of them and unions their failures (ValidatingCommandDecorator.cs:74-84,
ValidatingQueryDecorator.cs:77-86) rather than honoring only the first registration. That semantic
is deliberate and is recorded as the 2026-08-31 correction on ADR-014: a use case commonly carries a
module-authored validator beside a framework-supplied one, and stopping at the first registration
would turn the others into silently unenforced dead code
(ValidatingCommandDecorator.cs:18-22). The loop is sequential on purpose, not a Task.WhenAll: a
validator is free to reach the database through a scoped repository and a DbContext is not
thread-safe (ValidatingCommandDecorator.cs:70-72). Only when at least one failure is collected does
the decorator build a typed failure and return it without calling the handler
(ValidatingCommandDecorator.cs:86-94).
The failure-mapping boundary. FluentValidation speaks ValidationResult and ValidationFailure;
the rest of the codebase speaks the Result pattern
(ADR-013).
ValidationFailureExtensions
(MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ValidationFailureExtensions.cs:9) is
the one-line bridge: a C# extension(ValidationResult) block
(primer §4) whose ToErrors(source) projects each
failure into Error.Validation(f.ErrorCode, f.ErrorMessage, source, f.PropertyName)
(ValidationFailureExtensions.cs:19-21), which stamps
ErrorType.Validation
(MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Error.cs:37-38). Each decorator passes the
use-case type name as the source (ValidatingCommandDecorator.cs:83,
ValidatingQueryDecorator.cs:86), so a downstream consumer can see which command or query produced
the failures, and the failing property name travels as the error's target. ErrorType.Validation
is what the edge maps to HTTP 400: the client-side reader carries the inverse mapping explicitly
(MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ProblemDetailsResultReader.cs:105), and the
severity table ranks Validation at the caller-can-fix-it end so a multi-error failure is never
downgraded from a 403 or 500 to a 400
(MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ErrorTypeSeverity.cs:46). Nothing in the
domain references FluentValidation, and FluentValidation never sees an Error: neither library knows
the other exists, which is [Rubric §3, Clean Architecture] (dependencies point inward, the external
library stays at the Application boundary) and [Rubric §9, API and Contract Design] (one uniform
failure contract for every endpoint).
The rule sets: composition over copy-paste. Each rule class in
MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs is a tiny
AbstractValidator<T> generic over the parent type, taking an Expression<Func<T, ...>> selector
in its constructor and declaring its rules in an expression-bodied constructor. Because they are
generic-plus-selector, the same EmailRules<T>
(CommonValidationRules.cs:64) validates a value object, a request DTO, or a command; a module
composes it with FluentValidation's Include(...) instead of rewriting "non-empty, valid format, max
length" each time. The bounds are parameters, never literals in the rule: ADC's registration
validator passes UserInvariants.EmailMaxLength into EmailRules<RegisterRequest> and pairs it with
StrongPasswordRules<T> and two
RequiredStringRules<T>
(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:16-19).
The two password rules are the one place a literal bound is intentional: both pin 8 and 128
characters (CommonValidationRules.cs:179-180, :193-194), and
StrongPasswordRules<T> adds four complexity Matches rules for uppercase,
lowercase, digit, and non-alphanumeric (CommonValidationRules.cs:195-198). That pair is
[Rubric §11, Security] territory: the framework offers a weak-by-default floor and a strong
variant, and the module picks, so a consumer cannot accidentally ship a two-character password field.
[Rubric §1, SOLID] (one rule set, one responsibility; the composite assembles rather than
duplicates) and [Rubric §15, Best Practices and Code Quality] (no copy-pasted limits or messages)
are what the whole file is optimizing for.
One field, one error code. Every rule class takes an optional trailing errorCode, and
OptionalErrorCodeExtensions.WithOptionalErrorCode
(CommonValidationRules.cs:19, the method at :30-32) is the internal helper that applies it:
it returns the rule unchanged when the code is null, so every existing call site that omits it
behaves exactly as before, and calls FluentValidation's WithErrorCode when one is supplied. The
code is applied to every rule the class declares for that field, so one field answers under one
code (CommonValidationRules.cs:11-18); a field whose separate bounds must answer under distinct
codes still writes its own rules. This is what lets modules subclass a framework rule instead of
bypassing it: ADC's SessionEventIdRules<T> derives from
RequiredIdRules<T, TId> and passes "Session.EventId.Required"
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:24-29),
and Store's ProductCategoryIdRules<T> derives from
OptionalPositiveIdRules<T, TId> with
"Product.CategoryId.Invalid"
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/Validation/ProductValidationRules.cs:47-51).
The two id rules also encode a deliberate difference in what "missing" means:
RequiredIdRules<T, TId> uses NotEmpty, which rejects both 0 for an
integer key and Guid.Empty for a GUID key (CommonValidationRules.cs:133-139,
:147), while OptionalPositiveIdRules<T, TId> uses
GreaterThan(default(TId)) on a nullable and relies on FluentValidation skipping a comparison rule
when the property is null, so "positive when provided" needs no When clause and no per-pass
recompiled selector (CommonValidationRules.cs:154-158, :166).
One rule that shares its check with the domain. AbsoluteUrlRules<T>
(CommonValidationRules.cs:85) is the exception to "rule sets are self-contained": besides the
length bound it calls Must(BeAnAbsoluteHttpUrl) (CommonValidationRules.cs:90), and that predicate
delegates to CommonInvariants.EnsureUrlIsWellFormed
(CommonValidationRules.cs:92-93, the invariant at
MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:293-297) and keeps only
its IsSuccess. The validator and the domain invariant therefore answer identically, by construction
rather than by convention. What it buys is concrete: a plain bounded-string treatment accepts
javascript: and data: values that become executable the moment a link or an image renders them,
and the invariant's own suppression note says the check must run on the untrusted string, before
anything constructs a Uri from it (CommonInvariants.cs:289-292). Null or empty passes, since the
fields are optional (CommonInvariants.cs:295). ADC applies it to every stored external link:
sponsor logo, website, and LinkedIn URLs
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:34,
:64, :82), speaker LinkedIn and GitHub URLs
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:65,
:84), and the event sponsorship-packet and ticketing URLs
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:85,
:105). [Rubric §11, Security] assesses whether untrusted input is constrained at the boundary
before it reaches a rendering surface; this rule is where that happens for URLs.
The address family, a worked example of composition. The six rules in
MMCA.Common/Source/Core/MMCA.Common.Application/Validation/AddressValidationRules.cs follow the
same generic-plus-selector shape, and their length bounds come from
AddressInvariants constants rather than
literals (AddressValidationRules.cs:37, :47, :57, :67, :77, :87), so the value object and
its validator cannot disagree. Only line 1 is required (NotEmpty at AddressValidationRules.cs:36);
the other five are max-length only. AddressValidator
(AddressValidationRules.cs:13) then Includes all six against the
Address value object
(AddressValidationRules.cs:17-22). Consumers pick whichever half they need: ADC and Store's
registration validators and Store's customer create and change-address validators attach the whole
composite with SetValidator(new AddressValidator())
(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:22,
MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/Validation/RegisterRequestValidator.cs:36,
MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Customers/UseCases/Create/CustomerCreateRequestValidator.cs:20,
MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Customers/UseCases/ChangeAddress/CustomerChangeAddressRequestValidator.cs:18),
while a DTO that flattens address fields without an Address wrapper can include the individual
rule sets instead (AddressValidationRules.cs:9-11).
Convention over configuration: the request-to-command bridge. Most commands wrap a request record
and implement ICommandWithRequest<out TRequest>
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/ICommandWithRequest.cs:14). Rather than
make module authors write a validator for both the request and the command,
CommandRequestValidator<TCommand, TRequest>
(MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:30) takes
the whole IEnumerable<IValidator<TRequest>> from the container, de-duplicates it by runtime type so
an assembly scanned twice does not report each failure twice, and attaches each one with
RuleFor(c => c.Request).SetValidator(validator) (CommandRequestValidator.cs:33-41). The wiring is
reflective and lives in the module scan: ScanModuleApplicationServices
(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:181) first calls
FluentValidation's AddValidatorsFromAssembly to pick up every hand-written validator by convention
(DependencyInjection.cs:252), then walks the assembly for types implementing
ICommandWithRequest<>, constructs the closed CommandRequestValidator<TCommand, TRequest>, and
registers it as IValidator<TCommand> with TryAddTransient, so an explicitly authored command
validator always wins (DependencyInjection.cs:254-270). Common's own validators are registered
separately in AddApplication via AddValidatorsFromAssemblyContaining<ClassReference>(), because
the per-module scan only sees the module's own assembly (DependencyInjection.cs:48-51); that call
is what puts AddressValidator in the container. For a command the reflective
scan cannot see, for example a closed generic constructed at registration time,
AddCommandRequestValidator<TCommand, TRequest>() (DependencyInjection.cs:477-480) is the explicit
form of the same registration, with the same TryAdd precedence. [Rubric §2, Design Patterns]
(convention over configuration, plus the Decorator pattern the gate itself rides on) and
[Rubric §15, Best Practices & Code Quality] (a new command inherits validation without a registration line) both
land here.
The caller guard that travels with this group.
CurrentUserServiceExtensions
(MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:9) is
not FluentValidation, but it answers the other question asked before a handler does work: it collapses
the read-then-null-check-then-fail block into
RequireUserId(code, message, errorType, source) on
ICurrentUserService, returning
Result<UserIdentifierType> and defaulting to ErrorType.Forbidden with the shared
"Access denied." message (CurrentUserServiceExtensions.cs:12, :35-46). The error code stays a
parameter because it names the module, which the framework cannot know. ADC's Engagement handlers use
it as their first statement ("CheckIns.Forbidden" in the five check-in handlers, for example
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/CheckInAttendee/CheckInAttendeeHandler.cs:35,
and "Points.Forbidden" in the two points handlers, for example
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/UseCases/GetMyPoints/GetMyPointsHandler.cs:41).
Adopting it moved those codes out of the modules and into framework arguments, which is visible in
governance: ADC's error-catalog fitness test lowered its scanned-code floor to 57 to account for
codes the scanner can no longer see as module literals
(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Contracts/ErrorCatalogTests.cs:88-106). That is
[Rubric §34, Architecture Governance and Documentation] at work: a shared abstraction is not
adopted until the tests that measure the codebase are updated to match.
End to end, concretely. A request arrives at a controller or endpoint, is mapped to a command, and
is dispatched. The pipeline reaches the Validating stage, which resolves every IValidator<TCommand>
the container holds: hand-written ones found by the assembly scan, plus the auto-registered
CommandRequestValidator that forwards to the request's validators. Those validators run the composed
rule sets: ADC's session rules, for instance, derive SessionTitleRules<T> from
RequiredStringRules<T> and SessionEventIdRules<T> from
RequiredIdRules<T, TId>
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:13-14,
:24-25). If every validator passes, the Timeout and Transactional
decorators run and the handler executes inside a transaction. If any fails, ToErrors converts the
union of failures into ErrorType.Validation errors, the decorator logs a debug line naming the
command and the error count (ValidatingCommandDecorator.cs:97-107) and returns a failed Result
without touching the database; the edge maps it to a 400 with per-property messages. All of it
lives in MMCA.Common.Application so ADC, Store, and Helpdesk inherit the same email, password,
address, id, and URL rules and the same auto-validation convention. Module validators express only
domain-specific rules on top. There is no ADR dedicated to validation: it is governed by
ADR-014 for the gate and its
placement, ADR-013 for the failure
contract the gate emits, and by the architecture fitness tests that keep the layering honest.
OptionalErrorCodeExtensions
MMCA.Common.Application ·
MMCA.Common.Application.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:19· Level 0 · class (internal static)
What it is: a one-method internal helper that lets every reusable rule fragment in
CommonValidationRules.csaccept an optional machine-readable error code without any of them branching on it. Given a code it stamps the rule with FluentValidation'sWithErrorCode; givennullit hands the rule straight back untouched.Depends on: FluentValidation's
IRuleBuilderOptions<T, TProperty>(NuGet, imported atCommonValidationRules.cs:3). No first-party dependencies: this is the lowest primitive in the validation kit. Every rule class in the same file calls it, and its output feedsValidationFailureExtensions, which copies the failure'sErrorCodeonto the domainError.Concept introduced, the machine-readable error code alongside the human message. A FluentValidation failure carries two independent strings: a
ErrorMessagemeant for a person and anErrorCodemeant for a program. Left alone, FluentValidation fills the code with the validator's name ("NotEmptyValidator","MaximumLengthValidator"), which is useless to a caller that wants to branch on "the question text was missing" rather than "some non-empty rule failed somewhere". The class remarks (CommonValidationRules.cs:11-18) record the failure mode this helper exists to close: module validators used to skip the shared bases entirely and hand-write the rule chain for the single reason that the bases set a message but no code, so a validator needing a stable code had nothing to compose with.[Rubric §9, API & Contract Design]assesses whether the contract a client codes against is stable and uniform; a caller-supplied code such as"Sponsor.EventId.Required"is part of that contract, where the FluentValidation default is an implementation detail that would change if the rule were re-expressed.[Rubric §15, Best Practices & Code Quality]: because the code rides in as one optional constructor argument, adding a coded rule never forks the shared fragment into a bespoke copy. One deliberate consequence, spelled out in the same remarks: the code is applied to every rule the fragment declares for that field, so one field answers under one code, and a field whose bounds must answer under distinct codes still declares its own rules rather than reusing a fragment.Walkthrough: the class is
internal static(:19), so it is invisible outsideMMCA.Common.Application; consumers reach the behavior only through theerrorCodeparameter on the rule classes. Its single member,WithOptionalErrorCode<T, TProperty>(:30-32), is an extension method onIRuleBuilderOptions<T, TProperty>with an expression body that readserrorCode is null ? rule : rule.WithErrorCode(errorCode). Returning the sameIRuleBuilderOptionsin the null case is what makes it chainable in the middle of a fluent rule chain, and is what preserves byte-for-byte the behavior of the many callers that omit the code (:22-23documents exactly that intent).Why it's built this way: the alternative (an
ifinside each of the eleven rule constructors) would have broken their single-expression bodies and repeated the null check eleven times. Making it an extension keeps every fragment a one-liner while giving the whole file a uniform optional-code behavior.Where it's used: every rule chain in
CommonValidationRules.cs(:45-46,:57,:68-70,:89-90,:104,:115,:126,:147,:166,:178-180,:192-198). The address fragments inAddressValidationRules.csdo not use it: they take noerrorCodeparameter at all.
RequiredStringRules<T>, OptionalStringRules<T>, EmailRules<T>, PositiveIntRules<T>, PositiveDecimalRules<T>, NonNegativeIntRules<T>, RequiredIdRules<T, TId>, OptionalPositiveIdRules<T, TId>, PasswordRules<T>, StrongPasswordRules<T>
MMCA.Common.Application ·
MMCA.Common.Application.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs· Level 0 · classes (AbstractValidator<T>subclasses)
What it is: ten reusable FluentValidation rule fragments, each enforcing exactly one field contract (a required string with a length ceiling, a positive integer, a supplied identifier, a strong password). A request or command validator composes them with FluentValidation's
Include()instead of restating the sameRuleForchain. Each member, with its source line and the rule chain its constructor builds:Type File:Line Rule chain (all with .WithOptionalErrorCode(errorCode)per rule)RequiredStringRules<T>CommonValidationRules.cs:41NotEmpty()+MaximumLength(maxLength)OptionalStringRules<T>CommonValidationRules.cs:53MaximumLength(maxLength)only (nullable selector, null passes)EmailRules<T>CommonValidationRules.cs:64NotEmpty()+EmailAddress()+MaximumLength(maxLength)PositiveIntRules<T>CommonValidationRules.cs:100GreaterThan(0)overintPositiveDecimalRules<T>CommonValidationRules.cs:111GreaterThan(0)overdecimalNonNegativeIntRules<T>CommonValidationRules.cs:122GreaterThanOrEqualTo(0)overintRequiredIdRules<T, TId>CommonValidationRules.cs:142NotEmpty()overTId : notnull(rejects0andGuid.Empty)OptionalPositiveIdRules<T, TId>CommonValidationRules.cs:161GreaterThan(default(TId))overTId?(null passes)PasswordRules<T>CommonValidationRules.cs:174NotEmpty()+MinimumLength(8)+MaximumLength(128)StrongPasswordRules<T>CommonValidationRules.cs:188all of PasswordRules<T>plus fourMatches(...)regexesThe eleventh class in the file,
AbsoluteUrlRules<T>, is a Level 6 sibling (it reaches into a domain invariant) and is covered separately.Depends on: FluentValidation's
AbstractValidator<T>(NuGet, primer §3),System.Linq.Expressions.Expression<Func<T, ...>>andSystem.Globalization.CultureInfo(BCL,CommonValidationRules.cs:1-3), and the file-localOptionalErrorCodeExtensions. No first-party dependencies beyond that: these sit at the very bottom of the Application layer, which is why they carry no invariant constants and takemaxLengthas an argument. Bridged onto commands automatically byCommandRequestValidator<TCommand, TRequest>.Concept introduced, reusable FluentValidation rule fragments via
Include(). This is the first place the guide meets FluentValidation's fragment composition idiom. Rather than each validator owning a longRuleFor(...)chain, a fragment is a tinyAbstractValidator<T>whose constructor declares one field's rules, and a real validator pulls it in withInclude(new RequiredStringRules<RegisterRequest>(x => x.FirstName, "First name", UserInvariants.FirstNameMaxLength))(a live example atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:18). Two design choices make a fragment reusable across unrelated types: it is generic overT(the parent that holds the field), and it takes a selector expression rather than inheriting from that parent, so the sameEmailRules<T>validates aRegisterRequest, a bare value object, or a command with no inheritance coupling.[Rubric §24, Forms, Validation & UX Safety]assesses whether validation is defined once and reused rather than copy-pasted across create and update paths; these fragments are the framework's answer.[Rubric §1, SOLID]: each fragment has one responsibility (one field contract), so tightening the minimum password length is a one-line edit in one file rather than a search-and-replace across every module.[Rubric §11, Security]applies narrowly to the two password fragments: the complexity policy is expressed once, in framework code that both apps consume, so no module can accidentally ship a weaker one.Walkthrough: every constructor is a single expression body (
=>returning the configuredRuleForchain), which is why these classes are so terse. Each ends its parameter list withstring? errorCode = null.RequiredStringRules<T>(:43-46) takes(selector, fieldName, maxLength, errorCode)and chainsNotEmpty().WithMessage($"You must enter a {fieldName}")thenMaximumLength(maxLength). ThefieldNameis interpolated into the human message, so one fragment yields "You must enter a Title" or "You must enter a First name". The length message is built withstring.Create(CultureInfo.InvariantCulture, $"...")(:46) so the numeric limit formats identically whatever the ambient culture is.OptionalStringRules<T>(:55-57) drops theNotEmpty; its selector isFunc<T, string?>and only the ceiling is enforced.EmailRules<T>(:66-70) inserts FluentValidation's built-inEmailAddress()betweenNotEmptyandMaximumLength, with the message "You must enter a valid {fieldName}" (:69).PositiveIntRules<T>,PositiveDecimalRules<T>andNonNegativeIntRules<T>(:102-104,:113-115,:124-126) take only(selector, fieldName, errorCode), no length, and emit one comparison rule each. The first two share the message "{fieldName} must be a positive value"; the third reads "{fieldName} must be greater than or equal to 0".RequiredIdRules<T, TId>(:142-147) is constrainedwhere TId : notnull(:143) and usesNotEmpty(). The remarks (:133-139) explain why that operator and notGreaterThan(0):NotEmptyrejects0for an integer key andGuid.Emptyfor aGuidkey, which is exactly what "no id was supplied" looks like on the wire for both shapes. Its message interpolates the field phrase verbatim into "You must specify {fieldName}", so the caller supplies the article and any qualifier, for example"an Event for the Sponsor"(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:119).OptionalPositiveIdRules<T, TId>(:161-166) is constrainedwhere TId : struct, IComparable<TId>, IComparable(:162), takes a nullableExpression<Func<T, TId?>>selector and appliesGreaterThan(default(TId)). The remarks (:154-158) record the mechanism that keeps it cheap: FluentValidation skips a comparison rule when the nullable property holdsnull, so "must be positive when provided" needs noWhenclause and no selector recompiled per validation pass.PasswordRules<T>(:176-180) takes only(selector, errorCode): its messages are fixed strings, not parameterized by a field name. It enforces non-empty plus a length band of 8 to 128.StrongPasswordRules<T>(:190-198) repeats that band and adds fourMatches(...)calls whose regex literals are inline in the source:"[A-Z]","[a-z]","\\d"and"[^a-zA-Z\\d]"for uppercase, lowercase, digit and special character (:195-198). The doc comment onPasswordRules(:170-171) points callers who need complexity atStrongPasswordRules<T>instead.
Why it's built this way: these fragments are the DRY core of the validation story. Because they live in
MMCA.Common.Applicationand are generic, both ADC and Store inherit identical, tested field rules.[Rubric §33, Developer Experience]: a new request validator in any module reads as a short list ofInclude(...)calls, and a framework-level tightening propagates to every consumer on the next package bump instead of being missed in some forgotten validator. There is no dedicated ADR for validation; it is governed implicitly by the CQRS decorator design and the layering rules the architecture fitness tests enforce.Where it's used: modules consume them two ways. By subclassing, to bind a domain's invariant constant and error code once:
SpeakerFirstNameRules<T> : RequiredStringRules<T>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:12-17),SponsorEventIdRules<T> : RequiredIdRules<T, EventIdentifierType>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:115-120),ProductCategoryIdRules<T> : OptionalPositiveIdRules<T, CategoryIdentifierType>(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Application/Products/Validation/ProductValidationRules.cs:46-51), andCustomerEmailRules<T> : EmailRules<T>(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Customers/Validation/CustomerValidationRules.cs:33-34). By direct inclusion, when no domain constant is involved: ADC'sRegisterRequestValidatorincludesEmailRules,StrongPasswordRulesand twoRequiredStringRules(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:16-19), andChangePasswordRequestValidatorincludesStrongPasswordRulesfor the new password (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/ChangePasswordRequestValidator.cs:18). Whichever validator wins, it is invoked byValidatingCommandDecorator<TCommand, TResult>before the handler runs. Behavior is pinned byCommonValidationRulesTestsin group-27, which exercises both the message and the optional-code path for each fragment (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Validation/CommonValidationRulesTests.cs:440-538).
CommandRequestValidator<TCommand, TRequest>
MMCA.Common.Application ·
MMCA.Common.Application.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommandRequestValidator.cs:30· Level 1 · class (sealed)
What it is: an auto-registered
AbstractValidator<TCommand>for any command implementingICommandWithRequest<out TRequest>. It validates the command by delegating to theIValidator<TRequest>registrations for the embedded request payload, so a module author writes rules for the request only and the command is validated for free.Depends on:
ICommandWithRequest<out TRequest>(Level 0; itsRequestproperty is the bridge, declared atMMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Contracts/ICommandWithRequest.cs:17) and FluentValidation'sAbstractValidator<T>/IValidator<T>/SetValidator. Registered by the Application-layerDependencyInjectionscan.Concept introduced, convention-over-configuration validation.
[Rubric §2, Design Patterns]assesses whether recurring structure is handled by a named, reusable mechanism instead of repeated by hand;[Rubric §9, API & Contract Design]assesses whether input validation is applied uniformly at the edge. Most write-side commands are thin wrappers carrying a request DTO, for exampleCreateSessionCommand(CreateSessionRequest Request). Without this type a module would have to register a validator for both the request and the command. Instead the framework closesCommandRequestValidator<TCommand, TRequest>over the pair and routes command validation into the request's registered validators.Walkthrough: the class is sealed and constrained
where TCommand : ICommandWithRequest<TRequest>(:30-31), which is what makes thec.Requestselector compile. The constructor (:33-41) receivesIEnumerable<IValidator<TRequest>>by DI, null-guards it withArgumentNullException.ThrowIfNull(:35), then iteratesrequestValidators.DistinctBy(v => v.GetType())(:37) and callsRuleFor(c => c.Request).SetValidator(validator)once per surviving validator (:39). Three behaviors follow from those five lines, each documented on the class:- Every registered validator for the request runs, not just the first (
:11-17). That matches the policy the command and query decorators already apply toIValidator<TCommand>: a module that authors a validator beside a framework-supplied one expects both rule sets enforced, and honoring only the first registration would turn the rest into dead code. FluentValidation unions the failures of rules placed on one property. - Registrations are de-duplicated by runtime type (
:18-21), so a validator class registered twice (a module assembly scanned twice, say) reports each failure once rather than in duplicate. - An empty collection is not an error: the loop simply adds no rule, and the bridge is a no-op for
a request with no rules (the same point is made at
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:477-478).
- Every registered validator for the request runs, not just the first (
Why it's built this way: it removes the most common piece of validation boilerplate (restating request rules at the command level) while staying overridable. Registration is by
TryAdd, so the convention never blocks a bespoke case. Two registration paths exist, both usingTryAddTransient: the reflection scan inScanModuleApplicationServiceswalks the module assembly for commands implementingICommandWithRequest<>, builds the closed generic and registers it (DependencyInjection.cs:254-270,TryAddTransientat:267) afterservices.AddValidatorsFromAssembly(moduleAssembly)has already picked up every hand-written validator (:250); and the explicit helperAddCommandRequestValidator<TCommand, TRequest>()(DependencyInjection.cs:477-481) does the same thing for one pair, which is how the generic create/update/delete registration helpers wire their commands (:349,:405,:454). Because the explicitIValidator<TCommand>is registered first, it always wins.Where it's used: the closed generic is registered per command during module scanning. At runtime
ValidatingCommandDecorator<TCommand, TResult>injectsIEnumerable<IValidator<TCommand>>and materializes it into an array (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingCommandDecorator.cs:34,:36), skips validation entirely when the array is empty (:64), and otherwise runs every validator in turn (:73), accumulating failures throughValidationFailureExtensions.ToErrors(:82). Covered byCommandRequestValidatorTestsin group-27.
AddressLine1Rules<T>, AddressLine2Rules<T>, CityRules<T>, CountryRules<T>
MMCA.Common.Application ·
MMCA.Common.Application.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Validation/AddressValidationRules.cs· Level 4 · classes (sealed,AbstractValidator<T>)
What it is: four of the six per-field address rule fragments, each generic over the parent type
Tand configured with a selector expression, each pinning its length ceiling to anAddressInvariantsconstant rather than a literal. The remaining two of the six,StateRules<T>andZipCodeRules<T>, have the identical shape and are covered alongside their composite,AddressValidator.Type File:Line Rule chain Effective limit AddressLine1Rules<T>AddressValidationRules.cs:31NotEmpty()+MaximumLength(AddressInvariants.AddressLine1MaxLength)200 AddressLine2Rules<T>AddressValidationRules.cs:42MaximumLength(AddressInvariants.AddressLine2MaxLength)only200 CityRules<T>AddressValidationRules.cs:52MaximumLength(AddressInvariants.CityMaxLength)only100 CountryRules<T>AddressValidationRules.cs:82MaximumLength(AddressInvariants.CountryMaxLength)only100 The limits are
public static readonly intfields onAddressInvariants(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/AddressInvariants.cs:12,:15,:18,:27), the same constants the EF entity configurations use, so the column width and the validator cannot drift apart.Depends on:
AddressInvariantsfromMMCA.Common.Shared.ValueObjects.Contact(imported atAddressValidationRules.cs:4), FluentValidation, andSystem.Globalization/System.Linq.Expressionsfrom the BCL (:1-2). They share the shape of the Level 0 fragments in CommonValidationRules but not an inheritance chain, and they sit at Level 4 precisely because they reference the invariant constants instead of takingmaxLengthas an argument.Concept introduced, a validation fragment bound to a domain invariant. The Level 0 fragments are policy-free: the caller supplies the number. These four bake in the canonical number by reading it from the shared invariant class, which is the right trade when the field has exactly one meaning across the whole solution (an address line is an address line in every module).
[Rubric §4, DDD]assesses whether domain rules live in one authoritative place rather than being restated per use case; here the length policy lives with theAddressvalue object's invariants and the validator merely reads it.[Rubric §24, Forms, Validation & UX Safety]: because each fragment is generic overTand selector-driven, the sameCityRules<T>validates a bareAddressvalue object and a request DTO that carries loose address fields with noAddresswrapper, a reuse the class doc calls out explicitly (AddressValidationRules.cs:9-12).[Rubric §1, SOLID](SRP): one fragment per field.Walkthrough: all four are
sealedand each constructor is a single=>expression body.AddressLine1Rules<T>(:34-37) is the only one of the six withNotEmpty(), matching the domain invariantAddressInvariants.EnsureAddressLine1IsValid, which is likewise the only field checkEnsureAddressIsValidperforms (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/AddressInvariants.cs:34-40). Its selector is a non-nullableExpression<Func<T, string>>. The other three take a nullableExpression<Func<T, string?>>(:45,:55,:85) and enforceMaximumLengthonly, matching the fact that line 2, city and country are optional on the value object. EveryWithMessageinterpolates the actual numeric limit throughstring.Create(CultureInfo.InvariantCulture, $"...")(:37,:47,:57,:87), so the message a user sees quotes the same number the constant holds and formats culture-independently. Unlike the Level 0 fragments these constructors take noerrorCodeparameter, so their failures carry FluentValidation's default codes ("NotEmptyValidator","MaximumLengthValidator").Why it's built this way: several commands across both apps carry address fields; each can
Include(new CityRules<CreateEventRequest>(p => p.City))without copy-pasting a limit, and a change to the canonical limit flows fromAddressInvariantsinto every validator and every EF configuration at once. Splitting one address validator into six per-field fragments is what allows a DTO with only some address fields to reuse the relevant subset.Where it's used: within the current source their only production consumer is
AddressValidator, which includes each of them bound to the correspondingAddressproperty (AddressValidationRules.cs:17-19,:22). They are covered directly byAddressValidationRulesTestsin group-27 (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Validation/AddressValidationRulesTests.cs:19,:55,:82,:130).Caveats / not-in-source: an earlier edition of this guide described these fragments as derived from
RequiredStringRules<T>. That is stale: each extendsAbstractValidator<T>directly (AddressValidationRules.cs:31-32,:42-43,:52-53,:82-83). No ADC or Store validator currently includes them directly; the reuse-outside-Addressscenario the class doc describes is supported by the design but has no consumer in the source today.
StateRules<T>
MMCA.Common.Application ·
MMCA.Common.Application.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Validation/AddressValidationRules.cs:62· Level 4 · class (sealed)
- What it is: a one-rule FluentValidation fragment for the optional
Statefield of a postal address. It is generic over the parent typeTand takes a selector expression, so it can be included by any validator whose model carries a state / province / region string, not only by a validator for theAddressvalue object. - Depends on: FluentValidation's
AbstractValidator<T>(NuGet, primer §3),System.Linq.Expressions.Expression<Func<T, string?>>(BCL), andAddressInvariantsfor the length ceiling (MMCA.Common.Shared.ValueObjects, imported atMMCA.Common/Source/Core/MMCA.Common.Application/Validation/AddressValidationRules.cs:4). It sits at Level 4 purely because of that constant reference: structurally it is the same fragment shape as the Level-0OptionalStringRules<T>. - Concept: the reusable rule-fragment idiom introduced by
RequiredStringRules<T>and the otherMMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.csfragments, specialised to one address field.[Rubric §1, SOLID]assesses whether each unit has one reason to change; this class has exactly one, the state field's contract, and the number that expresses that contract lives in a single shared constant rather than being retyped at each call site.[Rubric §24, Forms, Validation & UX Safety]assesses whether validation is defined once and reused across create and update paths instead of copy-pasted; including this fragment is how a request validator gets the state rule without restating it. - Walkthrough: the whole class is a constructor with an expression body
(
AddressValidationRules.cs:65-67). It takesExpression<Func<T, string?>> selectorand callsRuleFor(selector).MaximumLength(AddressInvariants.StateMaxLength). The selector is nullable (string?), which encodes the fact that state is optional onAddress(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/Address.cs:32declaresStateasstring?): there is noNotEmpty()in the chain, sonulland""both pass and only an over-length value fails. The message is built withstring.Create(CultureInfo.InvariantCulture, $"State cannot be longer than {AddressInvariants.StateMaxLength} characters")(:67), so the number a user sees is the same constant the rule enforces, and the interpolation is culture-invariant rather than dependent on the ambient culture of the validating thread. The current value of that constant is100(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/AddressInvariants.cs:21). - Why it's built this way:
AddressInvariantsis described in its own doc comment as the single place the max lengths are shared with "EF entity configurations and FluentValidation validators" (AddressInvariants.cs:5-7). Binding the fragment to the constant rather than to a literal is what keeps the request-level rule, the domain invariant, and the column width from drifting apart. - Where it's used: composed into
AddressValidatoratAddressValidationRules.cs:20, and available for directInclude(...)by any module request validator whose DTO carries loose address fields. It is exercised directly byAddressValidationRulesTests(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Validation/AddressValidationRulesTests.cs:98), which buildsnew StateRules<TestAddressModel>(p => p.State)against a model type unrelated toAddressand is therefore a live demonstration that the fragment is genuinely parent-agnostic. - Caveats / not-in-source: the rule is length-only. There is no format, enumeration, or country-aware check on the state value anywhere in this class.
ZipCodeRules<T>
MMCA.Common.Application ·
MMCA.Common.Application.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Validation/AddressValidationRules.cs:72· Level 4 · class (sealed)
- What it is: the same one-rule fragment shape as
StateRules<T>, bound to the optionalZipCodefield and its own length constant. - Depends on: FluentValidation's
AbstractValidator<T>, a nullable selector expression, andAddressInvariants. - Concept: no new concept. See
StateRules<T>for the fragment idiom and the[Rubric §24, Forms, Validation & UX Safety]treatment; this section records only what differs. - Walkthrough: the constructor body (
AddressValidationRules.cs:75-77) isRuleFor(selector).MaximumLength(AddressInvariants.ZipCodeMaxLength)with the message "Zip Code cannot be longer than {n} characters", again built throughstring.Create(CultureInfo.InvariantCulture, ...).ZipCodeMaxLengthis20(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/AddressInvariants.cs:24), the tightest of the six address constants, and the field is nullable on the value object (MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/Address.cs:36), so an absent postal code is valid. - Why it's built this way: postal-code formats differ by country, so the framework-level rule deliberately bounds length only and leaves any national format rule to the module that knows the country context. Twenty characters is wide enough for the punctuated formats used outside the United States while still bounding the string for storage and for the EF column width that shares the constant.
- Where it's used: included by
AddressValidatoratAddressValidationRules.cs:21, and covered in isolation byAddressValidationRulesTests(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Validation/AddressValidationRulesTests.cs:114), which asserts a failure atZipCodeMaxLength + 1characters rather than at a hard-coded 21, so the test moves with the constant.
AddressValidator
MMCA.Common.Application ·
MMCA.Common.Application.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Validation/AddressValidationRules.cs:13· Level 5 · class (sealed)
- What it is: the composite
AbstractValidator<Address>for the wholeAddressvalue object. It declares no rules of its own: its entire body is sixInclude(...)calls that bind the six per-field fragments to the sixAddressproperties. - Depends on:
Address(the validated type, imported atAddressValidationRules.cs:4) and the six Level-4 fragmentsAddressLine1Rules<T>,AddressLine2Rules<T>,CityRules<T>,StateRules<T>,ZipCodeRules<T>, andCountryRules<T>. Through those it depends transitively onAddressInvariants, and on FluentValidation. - Concept introduced, composing a value-object validator out of field fragments.
[Rubric §2, Design Patterns]assesses whether recognised patterns are applied where they earn their keep; this is composition rather than inheritance, and FluentValidation'sInclude()is the mechanism: including a validator of the same generic argument merges its rules into the including validator's rule set, so a failure surfaces with the field's own property name and message exactly as it would if the fragment ran standalone.[Rubric §24, Forms, Validation & UX Safety]assesses reuse of validation across paths: because the composite lives inMMCA.Common.Applicationrather than in a module, ADC and Store validate an address identically, and the six fragments stay independently includable for the request DTOs that carry loose address fields without anAddresswrapper (a case the class doc calls out explicitly atAddressValidationRules.cs:8-12).[Rubric §14, Testability]: because each field's rule is a separate type, a test can construct one fragment over a throwaway model, which is whatAddressValidationRulesTestsdoes before also exercising the assembled composite (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Validation/AddressValidationRulesTests.cs:10holds the sharedAddressValidatorinstance;:144is the all-fields-valid case). - Walkthrough: the constructor (
AddressValidationRules.cs:15-23) runs six statements in property order:Include(new AddressLine1Rules<Address>(p => p.AddressLine1))(:17), thenAddressLine2Rules(:18),CityRules(:19),StateRules(:20),ZipCodeRules(:21), andCountryRules(:22). Each fragment is closed overAddressas itsT, and each selector picks the matching property.AddressLine1Rules<Address>is the only fragment in the set that contributes aNotEmpty()(AddressValidationRules.cs:36), which is what makes address line 1 the single required field of the value object; every otherIncludecontributes a max-length bound only. The class issealedand holds no state beyond the rules its constructor registers, so a call site can safely construct one per use. - Why it's built this way: the same invariants are also enforced inside the domain by
AddressInvariants.EnsureAddressIsValid(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/AddressInvariants.cs:35), which theAddressfactory calls. The validator exists so a bad address is rejected at the application boundary with a per-field, user-facing message instead of surfacing as a single factory failure after the command has already entered the pipeline; both paths read the same constants, so they cannot disagree on the bounds. - Where it's used: module request validators attach it with
SetValidator, notInclude, because the address is a nested object rather than a set of sibling fields on the request. ADC'sRegisterRequestValidatordoesRuleFor(x => x.Address).SetValidator(new AddressValidator()!).When(x => x.Address is not null)(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Validation/RegisterRequestValidator.cs:21-23). Store does the same for registration (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/Validation/RegisterRequestValidator.cs:36) and for customer creation (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Customers/UseCases/Create/CustomerCreateRequestValidator.cs:20), whileCustomerChangeAddressRequestValidator(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Customers/UseCases/ChangeAddress/CustomerChangeAddressRequestValidator.cs:17-18) attaches it with noWhenclause at all: its doc comment explains that the request'sAddressmember isrequiredand non-nullable, so FluentValidation's child-validator adapter already skips a null instance. Whichever validator wins, it reaches the runtime through the same route as every other one in this chapter: the resolvedIValidator<TCommand>set is run byValidatingCommandDecorator<TCommand, TResult>before the handler executes, and failures are mapped to domain errors byValidationFailureExtensions. - Caveats / not-in-source: the
!in the ADC and Store registration call sites suppresses a nullability warning onSetValidatorfor a nullable child property; it does not change the runtime behaviour of this validator.
AbsoluteUrlRules<T>
MMCA.Common.Application ·
MMCA.Common.Application.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:85· Level 6 · class
- What it is: a reusable fragment for an optional URL field. It applies two rules: a maximum
length, and a check that any supplied value is an absolute
httporhttpsURI. Anullor empty value passes both. - Depends on: FluentValidation's
AbstractValidator<T>, a nullable selector expression, the internalOptionalErrorCodeExtensionshelper in the same file, andCommonInvariantsfromMMCA.Common.Domain.Invariants(imported atMMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:4), which is what puts this fragment at Level 6 while its file-mates sit at Level 0. - Concept introduced, delegating a validator predicate to the domain invariant.
[Rubric §11, Security]assesses whether untrusted input is constrained before it reaches a sink. The rule's own doc comment states the threat plainly (CommonValidationRules.cs:77-83): a length-only bound acceptsjavascript:anddata:values, and those become executable the moment a linkhrefor an imagesrcrenders them. The scheme check is therefore not cosmetic URL hygiene, it is the boundary control for stored script injection through a user-supplied link.[Rubric §9, API & Contract Design]assesses uniform, machine-readable error contracts: the optionalerrorCodeparameter, applied throughOptionalErrorCodeExtensions, lets a module attach a stable code such asSponsor.LogoUrl.Invalidto both rules so one field answers under one code, which is the reason module validators no longer need to bypass the shared fragments to get a code.[Rubric §15, Best Practices & Code Quality]: the predicate is not a second implementation of the scheme test, it calls the domain invariant, so the request-level answer and the entity-level answer cannot diverge. - Walkthrough:
- The constructor (
CommonValidationRules.cs:87-90) takes(Expression<Func<T, string?>> selector, string fieldName, int maxLength, string? errorCode = null)and builds a single chain:MaximumLength(maxLength)with the interpolated "{fieldName} cannot be longer than {maxLength} characters" message, then.Must(BeAnAbsoluteHttpUrl)with "{fieldName} must be an absolute http or https URL". Each rule ends in.WithOptionalErrorCode(errorCode)(:89,:90), which returns the rule untouched when the code isnull(CommonValidationRules.cs:30-32), so every existing caller that omits it keeps FluentValidation's default per-rule code. - The length message uses
string.Create(CultureInfo.InvariantCulture, ...)while the scheme message is a plain interpolation: the first embeds a number and so pins the culture, the second embeds only the field name. BeAnAbsoluteHttpUrl(:92-93) is aprivate staticpredicate that callsCommonInvariants.EnsureUrlIsWellFormed(url, "Url.Invalid", "Url.Invalid", nameof(AbsoluteUrlRules<>), "url")and returns.IsSuccess. Two details are worth reading twice. First, the invariant returns aResultand this call site discards everything except the boolean: the code, message, source, and target passed in are the invariant's own error shape, not what the user sees. The message the field actually reports is the FluentValidationWithMessagetext on:90, and the code is whatevererrorCodethe caller supplied. Second,nameof(AbsoluteUrlRules<>)uses the unbound generic form and evaluates to the plain string"AbsoluteUrlRules".- The invariant itself
(
MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:293-297) passes when the string is null or empty, otherwise defers to the privateIsAbsoluteHttpUrl(CommonInvariants.cs:436-439), which requiresUri.TryCreate(url, UriKind.Absolute, out var uri)to succeed anduri.Schemeto equalUri.UriSchemeHttporUri.UriSchemeHttpsunderStringComparison.Ordinal. That is an allow-list, not a deny-list:mailto:,ftp:,file:, and any relative value are refused along withjavascript:anddata:. - Because the empty case passes, the fragment is genuinely optional-field-shaped; a caller that needs
the URL to be present pairs it with a required rule, which is what the ADC wrappers do with a
When(...)guard.
- The constructor (
- Why it's built this way: the invariant carries the doc comment explaining that length is a
separate concern to be composed via
Result.Combine(MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:280-281), and this fragment is the application-layer counterpart of that composition: oneMaximumLengthrule plus one scheme rule, both reporting per field.[Rubric §26, Front-End Security]extends the same reasoning to the browser:AbsoluteUrlAttribute(MMCA.Common/Source/Presentation/MMCA.Common.UI/Validation/AbsoluteUrlAttribute.cs:26) applies the identicalUri.TryCreateplus ordinal scheme comparison (AbsoluteUrlAttribute.cs:44-45) as a DataAnnotations rule and names this class in its doc comment as the server rule it mirrors, so a form gives the verdict the API would give rather than sending the user on a round trip to find out. The two are separate implementations by necessity (the UI package does not reference the Application layer), and the parity is held by tests on both sides rather than by a shared call. - Where it's used: the ADC Conference module wraps it once per URL-bearing field, always inside a
When(x => !string.IsNullOrWhiteSpace(accessor(x)), ...)guard so a blank optional field reports nothing:SponsorLogoUrlRules<T>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:34),SponsorWebsiteUrlRules<T>(:64), the sponsor LinkedIn rule (:82),SpeakerLinkedInUrlRules<T>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:65),SpeakerGitHubUrlRules<T>(:84) and the speaker website rule (:103),ActivityVenueUrlRules<T>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:71), andEventSponsorshipPacketUrlRules<T>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:85). Each wrapper supplies its own module max-length constant. Direct coverage lives inCommonValidationRulesTests(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Validation/CommonValidationRulesTests.cs:565for the passing shapes,:580for rejected schemes,:592for the length bound, and:604for the supplied-error-code path). - Caveats / not-in-source: the rule constrains the scheme only. It does not check that the host resolves, that the URL is reachable, or that the target is safe to embed, and it places no restriction on host, port, or path.
ValidationFailureExtensions
MMCA.Common.Application ·
MMCA.Common.Application.Extensions·MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/ValidationFailureExtensions.cs:9· Level 2 · class (static)
What it is: a static class wrapping a single C#
extension(ValidationResult)block that adds one method,ToErrors(string source), which converts FluentValidation'sValidationFailureentries into domainErrorinstances. It is the one adapter that lets FluentValidation output flow into the codebase's Result pattern.Depends on:
Errorand itsErrorType.Validationclassification (imported fromMMCA.Common.Shared.Abstractions,ValidationFailureExtensions.cs:2);FluentValidation.Results(ValidationResult,ValidationFailure, NuGet,ValidationFailureExtensions.cs:1); LINQSelect.Concept introduced, bridging FluentValidation to the Result pattern.
[Rubric §15, Best Practices & Code Quality](assesses consistent, idiomatic conventions over one-off code; the convention here is validators produce domainErrors, never raw strings and never thrown exceptions). A FluentValidationAbstractValidator<T>produces aValidationResultwhoseErrorsare framework-specificValidationFailureobjects; everything downstream speaks inResultfailures carrying domainErrors. This extension is the only place that translation happens, so no pipeline stage, handler, or API filter is coupled to FluentValidation's failure shape. It also touches[Rubric §9, API & Contract Design](assesses uniform, standardized error responses): every failure becomes anErrorType.Validationerror, the classification the API layer maps to HTTP 400, so a validation failure looks identical at the boundary no matter which validator raised it.Walkthrough: the file is a static class (
ValidationFailureExtensions.cs:9) holding a singleextension(ValidationResult result)block (:11). Inside it,ToErrors(string source)(:19) projectsresult.Errorswith a LINQSelect(:20) into oneError.Validation(...)call per failure (:21), passing four arguments in order: the failure'sErrorCodebecomes the errorcode, itsErrorMessagebecomes themessage, the caller-suppliedsourcebecomes thesource, and itsPropertyNamebecomes thetarget.Error.Validationis declaredValidation(string code, string message, string? source = null, string? target = null)inMMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Error.cs:37, soPropertyNamelands in thetargetslot, identifying which field failed whilesourceidentifies what operation was being validated. The method returnsIEnumerable<Error>lazily; the caller materializes it.Why it's built this way: the C#
extension(T)syntax (see primer §4) lets the conversion read as a natural method onValidationResult(result.ToErrors("X")) without subclassing FluentValidation and without a static helper every consumer must remember to reach for. It keeps the cross-cutting mapping co-located with its purpose and out of both FluentValidation andError: neither library knows the other exists. Returning a lazyIEnumerable<Error>defers the projection until the caller enumerates, which the call sites do immediately (AddRangein the decorators, theResult.Failurefactory in the auth base).Where it's used: five call sites in
MMCA.Common.Application, all following the same shape.ValidatingCommandDecorator<TCommand, TResult>loops over every registeredIValidator<TCommand>and, for each result that is not valid, doeserrors.AddRange(validationResult.ToErrors(typeof(TCommand).Name))(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingCommandDecorator.cs:83, accumulating into theList<Error>? errorsdeclared at:72and lazily allocated at:81).ValidatingQueryDecorator<TQuery, TResult>does the identical thing withtypeof(TQuery).Name(MMCA.Common.Application/UseCases/Decorators/ValidatingQueryDecorator.cs:86). Note that the accumulate-across-all-validators shape is deliberate: the decorator's own doc comment (ValidatingCommandDecorator.cs:18-23) records that running only the first registered validator would turn the rest into silently unenforced dead code, and that collecting all of them lets the caller see every broken rule in one response instead of one per round trip. The remaining three call sites are inAuthenticationServiceBase<TUser>, which validates its request before touching the user store and passes the method name assource:nameof(LoginAsync)(MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:130),nameof(RegisterAsync)(:193), andnameof(RefreshTokenAsync)(:273), each wrapping the result inResult.Failure<AuthenticationResponse>(...). Covered byValidationFailureExtensionsTests.Caveats / not-in-source: the failure's
ErrorCodeis FluentValidation's per-rule code (for example"NotEmptyValidator") unless a validator overrides it with.WithErrorCode(...); this extension passes it through verbatim and neither normalizes nor validates it. Separately, the nameToErrorsis reused by an unrelated extension in the gRPC layer,ResultGrpcExtensionsdeclares anextension(Metadata? trailers)block with its ownToErrors()(MMCA.Common/Source/Presentation/MMCA.Common.Grpc/ResultGrpcExtensions.cs:146and:165) that decodes errors out of gRPC trailers. Different receiver, different assembly, no relationship to this one.
CurrentUserServiceExtensions
MMCA.Common.Application ·
MMCA.Common.Application.Extensions·MMCA.Common/Source/Core/MMCA.Common.Application/Extensions/CurrentUserServiceExtensions.cs:9· Level 9 · class (static)
What it is: a static class holding one public constant and one
extension(ICurrentUserService)member,RequireUserId(...), which turns "who is calling, and fail if nobody is" into a single expression returning aResult<UserIdentifierType>. It is a validation-shaped caller guard: the same short-circuit-before-you-work move the validating decorators make, applied to identity rather than to command fields.Depends on:
ICurrentUserService(the extended type,CurrentUserServiceExtensions.cs:1and:14),Result,ErrorandErrorType(:2), and theUserIdentifierTypeidentifier alias (global using UserIdentifierType = int;inMMCA.Common/Source/Core/MMCA.Common.Domain/GlobalUsings.IdentifierType.cs:1, see primer). The only BCL dependency isArgumentNullException.ThrowIfNull.Concept: pushing a repeated guard into the framework rather than a base class.
[Rubric §15, Best Practices & Code Quality](assesses whether duplicated logic is consolidated where it can only be written once): the guard being replaced is the three-line read-UserId, null-check, build-a-forbidden-Error, return-a-failure block that every handler and controller protecting a per-user operation would otherwise repeat, which the type's own doc comment states atCurrentUserServiceExtensions.cs:17-19.[Rubric §11, Security](assesses that authentication and authorization decisions are made server-side and consistently): because the failure is manufactured in one place, no module can accidentally return a 200 with a default identifier for an unauthenticated caller, and no module can drift on the message.[Rubric §1, SOLID]: the guard is added by extension rather than by a handler base class, so it composes onto anyICurrentUserServiceimplementation (production service, hand-written test double, or mock) without an inheritance constraint on the consumer.Walkthrough:
AccessDeniedMessage(:12) is apublic const stringfixed to"Access denied.", documented as the message every app-side copy of this guard reports; making it a named constant is what lets a test assert the framework and the modules agree on one string. Theextension(ICurrentUserService currentUserService)block (:14) contributes the single instance methodRequireUserId(:35), whose four parameters are:code, required, the module's error code for a denied caller (for example"CheckIns.Forbidden"), kept a parameter because the code names the module and the framework cannot know that (:21-24);message, defaulting toAccessDeniedMessage(:37);errorType, defaulting toErrorType.Forbidden(:38), which is what the handler-side copies of the guard report, withErrorType.Unauthorizeddocumented as the value to pass where the edge answers 401 instead (:26-30); and an optionalsource(:39) for the calling handler's name. The body is three lines:ArgumentNullException.ThrowIfNull(currentUserService)(:41), which matters because an extension member on an interface is callable on a null reference without an NRE at the call site, then a single expression (:43-45) that pattern-matchescurrentUserService.UserId is { } userIdand returns eitherResult.Success(userId)orResult.Failure<UserIdentifierType>(new Error(code, message, errorType, source)). Theis { }property pattern is the null test and the unwrap in one step:UserIdis declaredUserIdentifierType?onICurrentUserService(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Auth/ICurrentUserService.cs:15), anduserIdis the non-nullableintbehind it. Note the failure path constructs theErrorrecord directly rather than through a factory, because the classification is a caller-supplied parameter here and the factories (Error.Forbidden,Error.UnauthorizedatMMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Error.cs:82and:73) each hard-code oneErrorType.Result.Failure<T>(Error)wraps the single error into the result's error list (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Result.cs:101).Why it's built this way: the parameter set is the minimum that could not be inferred. Everything the framework can know (the message, the classification, the null handling, the
Resultshape) has a default; the one thing it cannot know, the module-scoped error code, stays required. That choice has a measurable side effect recorded in ADC's architecture fitness suite: moving"CheckIns.Forbidden"and"Points.Forbidden"out of module-sideErrorfactory calls and into arguments of this framework member made them invisible to the IL literal scan that counts ADC's error codes, which is part of whyErrorCatalogTests.MinimumErrorCodessits at 57 (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Contracts/ErrorCatalogTests.cs:95-106). The codes ship unchanged; only the scanner's visibility of them changed.Where it's used: six ADC Engagement handlers call it as their first statement after the null guard, then branch on
caller.IsFailureand propagatecaller.Errors:RecordSponsorVisitHandler(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:50),RecordRoomCheckInHandler(.../CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:41),ManualCheckInHandler(.../CheckIns/UseCases/ManualCheckIn/ManualCheckInHandler.cs:31),GetOrCreateMyBadgeHandler(.../CheckIns/UseCases/GetOrCreateMyBadge/GetOrCreateMyBadgeHandler.cs:28) andCheckInAttendeeHandler(.../CheckIns/UseCases/CheckInAttendee/CheckInAttendeeHandler.cs:33), all passing"CheckIns.Forbidden"; plusSetLeaderboardParticipationHandler(.../Points/UseCases/SetLeaderboardParticipation/SetLeaderboardParticipationHandler.cs:43) andGetMyPointsHandler(.../Points/UseCases/GetMyPoints/GetMyPointsHandler.cs:40) passing"Points.Forbidden". All of them take the defaults, so every one reports"Access denied."withErrorType.Forbidden. Behavior is pinned byCurrentUserServiceExtensionsTests, which asserts the success value, the default forbidden failure, the fully-overridden failure, theArgumentNullExceptionon a null service, and thatAccessDeniedMessagestill equals"Access denied."(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Extensions/CurrentUserServiceExtensionsTests.cs:16-72).Caveats / not-in-source: the name
CurrentUserServiceExtensionsis used twice in the workspace for two unrelated static classes. This one is the framework guard inMMCA.Common.Application.Extensions; ADC's Conference API ships its ownCurrentUserServiceExtensionsinMMCA.ADC.Conference.API.Authorizationwhose single member isIsPrivilegedConferenceReader(), a read visibility check over role names (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs:10-26). They share a name and an extended type and nothing else. Also noteRequireUserIdanswers only "is there an authenticated user", it makes no permission decision; capability checks stay with the[HasPermission(...)]attributes on the endpoints.
⬅ CQRS: Commands, Queries & the Decorator Pipeline • Index • Persistence & EF Core ➡