Onboarding guide
1. Result & Error Handling
This is the first capability chapter, and it is deliberately first because the pattern it teaches
underpins almost every other one in the guide. Before you read a command handler, a domain factory,
a controller action, or a gRPC call in any later chapter, you need the Result pattern in your
head: in this codebase, an operation that can fail in an expected way does not throw, it
returns a value that is either a success or a structured failure. The types in this chapter are
that value, the error it carries, the classification that drives HTTP status codes, the collection
envelopes that paged reads come back in, the JSON converter that lets a result survive a round trip
through the distributed cache, and the two narrow exception types reserved for the cases where
returning a value is genuinely impossible. (The primer introduces the idea in
§2; this chapter is where it becomes
concrete.) This is ADR-013 (the Result pattern): expected failures are transport-agnostic
Result/ErrorType values, only the edge maps them to HTTP/gRPC, and exceptions are reserved for the
genuinely exceptional.
Why a return value instead of an exception. Exceptions are expensive (stack capture and
unwinding), they are invisible in a method's signature, and they conflate programmer errors with
business outcomes. "This order ID doesn't exist" and "this email is already taken" are not
exceptional, they are routine, expected branches of normal control flow. Modeling them as data lets
the compiler see them (a method that returns Result<T> advertises that it can fail), lets
them be collected into a list, passed through a pipeline, inspected, and mapped to an HTTP response
without any try/catch. That decision is the single most pervasive idiom in the two repos:
practically every entity factory method, every CQRS command and query handler, every controller
action, and every service method returns Result or Result<T>. This touches
[Rubric §2, Design Patterns] (which assesses whether patterns are idiomatic and solve real problems
rather than being "pattern theater", here Result is the genuine, codebase-wide error-flow mechanism)
and [Rubric §9, API & Contract Design] (which assesses consistent, standardized error responses,
because every failure flows through the same envelope, every endpoint produces the same error shape).
The three-layer split. The pattern is intentionally factored into three small, dependency-free
pieces that each do one job, all living in MMCA.Common.Shared, the innermost layer that even the
Blazor WebAssembly UI can reference without dragging in EF Core or ASP.NET:
ErrorType(Level 0) is the classification axis, an eight-value enum (Validation,Invariant,NotFound,Conflict,Unauthorized,Forbidden,UnprocessableEntity,Failure) where each member's doc comment names the HTTP status it maps to (Validation/Invariant/Failureto 400,NotFoundto 404,Conflictto 409,Unauthorizedto 401,Forbiddento 403,UnprocessableEntityto 422;MMCA.Common.Shared/Abstractions/ErrorType.cs:8-33). Note that this HTTP-shaped concept lives in the domain core as a pure enum with no reference to ASP.NET,[Rubric §3, Clean Architecture](dependencies point inward; the inner layers stay framework-free), with the actualErrorType -> statustranslation deferred to the API layer.Error(Level 1) is the carrier, an immutable positionalrecord(MMCA.Common.Shared/Abstractions/Error.cs:15) with a machine-readableCode(e.g."Order.NotFound", for programmatic branching), a human-readableMessage(for clients), anErrorType, and optionalSource/Targetcontext. Eight factory methods (one perErrorType,Error.cs:37-101) each hard-code the correct type, so a caller can never accidentally pairError.NotFoundError(...)with the wrong classification; three pre-built static singletons (Error.NotFound,Error.AlreadyDeleted,Error.InvalidEntityField,Error.cs:23-29) cover the ubiquitous cases without re-allocating, andWithSource/WithTarget(Error.cs:106,112) enrich an error viawith-expression copies.Result(Level 2) is the outcome envelope (MMCA.Common.Shared/Abstractions/Result.cs:18), either a success (no errors) or a failure carrying one or moreErrors. The genericResult<T>(same file,Result.cs:119) adds aValueon the success path and the functional combinators that make the pattern ergonomic.
The railway, in one picture. "Railway-oriented programming" is the mental model: imagine two
parallel tracks, a success track and a failure track. An operation that takes the current result as
input is skipped if the result is already a failure (the train stays on the failure track), and runs
only if it is a success, where it may stay on the success track or switch to failure. Control flows
forward without nested if (result.IsFailure) checks. The generic Result<T> exposes exactly three
combinators for this, deliberately kept minimal because they cover the three real shapes:
Match(onSuccess, onFailure) (Result.cs:141) terminates the railway by collapsing both tracks to a
single value (exactly one branch runs); Map(mapper) (Result.cs:157) transforms the success value
while propagating errors untouched; and BindAsync(binder) (Result.cs:170) is the monadic bind
for async continuations, it short-circuits on failure (returning the original errors without calling
binder) and otherwise awaits the next operation, which itself returns a Result. The non-generic
base also offers Result.Combine(params ReadOnlySpan<Result>) (Result.cs:87), the
aggregate-all-failures combinator that runs several invariant checks and returns all their
errors at once rather than failing on the first, this is the workhorse of domain factory methods
(Result.Combine(CheckName(name), CheckDate(date), ...)), and its ReadOnlySpan parameter avoids a
heap allocation in the common case.
A note on construction discipline. You cannot new a Result<T> directly, its constructors are
internal (Result.cs:126,130), so the only way to produce one is through the static factory methods
on the base Result class (Success, Failure, Result.cs:43-78). This is what
guarantees the invariant the rest of the codebase relies on: a result is always either a clean
success or a non-empty failure, never a half-built object (the success path is even served by a single
cached immutable instance, Result.cs:20,43, so the common case allocates nothing). Likewise,
Error's factory methods are the canonical construction path because they fix the
Code -> ErrorType pairing at the call site.
Making a result survive a round trip. That same construction discipline creates a problem the
moment a result must be serialized: because Result<T>'s constructors are internal and
its properties are get-only, System.Text.Json's default reflection-based deserializer cannot rehydrate
one from JSON. That matters because the CQRS query-caching decorator
(Chapter 5/Chapter 9) serializes cached handler
results to Redis and reads them back later. ResultJsonConverterFactory
(Level 2) is the fix: a JsonConverterFactory attached to both Result and Result<T> via
[JsonConverter(...)] (Result.cs:17,118) whose CanConvert matches the non-generic Result and any
closed Result<T> (MMCA.Common.Shared/Serialization/ResultJsonConverterFactory.cs:21-23) and whose
CreateConverter hands back the right per-type converter (ResultJsonConverterFactory.cs:26-33). It
writes a compact {"value": ..., "errors": [...]} shape and, crucially, reconstructs the object
through the public factory methods (Result.Success/Result.Failure,
ResultJsonConverterFactory.cs:49,77), so a round-tripped result still obeys the same
success-or-non-empty-failure invariant a freshly built one does. ResultConverter
(Level 2, ResultJsonConverterFactory.cs:35) is the concrete JsonConverter<Result> for the
non-generic case (a generic ResultConverter<T> sibling handles the typed case), and both lean on one
small private helper, the PropertyReader (Level 0) delegate
(ResultJsonConverterFactory.cs:95), a ref Utf8JsonReader callback that the shared ReadObject
walker invokes once per JSON property so the value- and error-reading logic is written once and reused
by both converters. This is quiet [Rubric §12, Performance & Scalability] plumbing: without it the
distributed result cache could not exist, and with it the cache stores nothing but the framework's own
canonical wire shape.
How a failure becomes an HTTP response (the end-to-end flow). Follow a typical write through the
layers. A domain factory or aggregate method validates its inputs and returns
Result.Combine(...); a command handler in the application layer chains follow-on work with
BindAsync/Map, so any failure short-circuits the rest of the slice; the controller receives the
Result<T> and, on failure, hands it to ApiControllerBase.HandleFailure
(ApiControllerBase in the API chapter), which
reads the first error's ErrorType and maps it, via
ErrorHttpMapping's FrozenDictionary, to the
right status code, then renders an RFC 9457 Problem Details body. The same Result failure can
also cross a service boundary: on the gRPC boundary,
ResultGrpcExtensions turns a failure into an
RpcException carrying the errors over the wire (ADR-007), and the typed client turns it back into a
Result on the far side, so a remote call looks like a local one to application code. Because the
whole journey is driven by the same eight-member enum, the HTTP and gRPC error shapes are uniform
across every endpoint and every extracted service, there is one source of truth for "what does a
not-found look like."
The two exceptions, and why they exist anyway. A return-value pattern still needs a fallback for
the places where you genuinely cannot return one. DomainException (Level 0,
MMCA.Common.Shared/Exceptions/DomainException.cs:9) is the abstract base for domain-layer
exceptions, and DomainInvariantViolationException
(Level 1, MMCA.Common.Shared/Exceptions/DomainInvariantViolationException.cs:9) is its one concrete
subclass. The doc comments are emphatic that these are a last resort: prefer Result with
Error.Invariant for normal business-rule violations, and reserve the exception for contexts where
the Result pattern is structurally unavailable, most notably inside aggregate constructors invoked
by EF Core materialization (DomainInvariantViolationException.cs:4-7), where the call stack is
framework-owned and there is no Result channel to return through. When one of these does escape, the
API layer's DomainExceptionHandler middleware
(DomainExceptionHandler) catches it and
converts it to the same Problem Details shape (DomainException.cs:6-7), so even the exceptional path
lands on a consistent contract. This is the considered version of [Rubric §2, Design Patterns]:
exceptions are for the truly exceptional (programming errors, corrupted persistent state), not for
control flow.
Paged and collection reads ride the same rails. The read side needs an envelope too. A query that
returns a list wraps it in CollectionResult<T> (Level 0), a thin
[DataContract] record (MMCA.Common.Shared/Abstractions/PaginationMetadata.cs:64) with a single
required Items property whose constructor normalizes any IReadOnlyCollection<T> into a list
(PaginationMetadata.cs:74-78). When the read is paged,
PagedCollectionResult<T> (Level 1, PaginationMetadata.cs:91) extends
that base with one extra required property, PaginationMetadata (Level 0,
PaginationMetadata.cs:12), the server-side paging state (TotalItemCount, PageSize,
CurrentPage) plus computed, non-serialized derivations (TotalPageCount, FirstRowOnPage,
LastRowOnPage, PaginationMetadata.cs:45-55, each marked [IgnoreDataMember]). The
[DataMember(Order = ...)] annotations fix a deterministic wire order so PaginationMetadata always
serializes after Items (PaginationMetadata.cs:81,110). These envelopes are not part of the
success/failure machinery themselves, a paged query handler returns
Result<PagedCollectionResult<T>>, composing the two ideas, but they belong in this chapter because
they are the canonical shapes that successful reads return, the read-side counterpart to the
Result that every write returns. This touches [Rubric §9, API & Contract Design] (stable,
right-sized response contracts) and [Rubric §12, Performance & Scalability] (paging large result
sets so a query never pulls an unbounded list).
Where this leads. With these eleven types you have the full error-and-result vocabulary the rest
of the guide assumes. You will see Result returned by the domain building blocks of
Chapter 2 (factory methods that refuse to construct an invalid
entity), threaded through the CQRS decorator pipeline of
Chapter 5 (where a Result.Failure deliberately commits the transaction
but skips cache invalidation), cached across a Redis round trip by that same pipeline
(Chapter 9), and unwrapped at the edges by the API and gRPC layers
(Chapter 12, Chapter 13). Read this
chapter's type sections next; everything after them takes the railway for granted.
CollectionResult<T>
MMCA.Common.Shared ·
MMCA.Common.Shared.Abstractions·MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/PaginationMetadata.cs:64· Level 0 · record
- What it is: a thin envelope wrapping a collection of items for API responses; the base type for the paged variant.
- Depends on: nothing first-party. (Lives in the same file as
PaginationMetadataandPagedCollectionResult<T>.) - Concept introduced, the collection envelope. Returning a named wrapper (
{ items: [...] }) instead of a bare JSON array is a small but deliberate[Rubric §9, API & Contract Design]choice (§9 assesses consistent, evolvable response contracts): a wrapper leaves room to add metadata, like pagination, without a breaking change to the response shape, which a top-level array would force. - Walkthrough
- One property:
required ICollection<T> Items { get; init; }(PaginationMetadata.cs:82). Therequiredkeyword forces callers to set it (soItemsis never null); both constructors are marked[SetsRequiredMembers](lines 67, 73) to satisfy that requirement when constructed directly. - The data constructor (lines 74-78) null-checks input via
ArgumentNullException.ThrowIfNulland normalizes to aList<T>, reusing the list if the input already is one, a small allocation optimization for the common case. [DataContract]on the type and[DataMember(Order = 1)]onItems(lines 63, 81) pin the wire shape, matchingPaginationMetadata's serialization discipline.
- One property:
- Why it's built this way:
required+initgives "set once, never null" semantics without a hand-written constructor guard for the common path, while the explicit[DataContract]keeps the serialized shape stable. - Where it's used: base of
PagedCollectionResult<T>(Level 1); returned by non-paged collection endpoints across both apps.
DomainException
MMCA.Common.Shared ·
MMCA.Common.Shared.Exceptions·MMCA.Common/Source/Core/MMCA.Common.Shared/Exceptions/DomainException.cs:9· Level 0 · class (abstract)
- What it is: the abstract base for domain-layer exceptions.
- Depends on:
System.Exception(BCL) only. - Concept introduced, exceptions reserved for the truly exceptional.
[Rubric §2, Design Patterns](§2 assesses whether patterns are idiomatic and solve real problems; a classic red flag is exceptions used for control flow where a Result is the convention). The doc comment (DomainException.cs:3-8) is explicit: prefer theResultpattern for expected error paths; reserve exceptions for programming errors / corrupted state. So this type exists, but it is the exception (pun intended) to the rule, its concrete subclasses are caught byDomainExceptionHandlermiddleware (API layer) and converted to RFC 9457 Problem Details responses. - Walkthrough: three
protectedconstructors (parameterless, message, message+inner,DomainException.cs:12-23), the standard exception constructor set.abstractso you must derive a specific exception rather than throwing the base directly. - Why it's built this way: a single domain-exception root lets the middleware catch "domain exceptions" as a category and map them consistently, while the Result pattern handles the common case. It keeps the abstraction pure:
DomainExceptionlives inSharedwith no HTTP coupling; the status mapping happens later in the API layer. - Where it's used: base of
DomainInvariantViolationException(Level 1); caught byDomainExceptionHandlerAPI middleware.
ErrorType
MMCA.Common.Shared ·
MMCA.Common.Shared.Abstractions·MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ErrorType.cs:8· Level 0 · enum
- What it is: an enum that classifies every domain error into one of eight categories, each of which maps to an HTTP status code at the API boundary.
- Depends on: nothing first-party (BCL only). It is the seed of the Result-pattern family:
Error(Level 1) carries anErrorType, andResult(Level 2) carriesErrors. - Concept introduced, the Result pattern.
[Rubric §2, Design Patterns](§2 assesses whether patterns are idiomatic and solve real problems, not "pattern theater"; here the Result pattern is the codebase's canonical error-flow mechanism). Instead of throwing exceptions for expected failures (validation, not-found, conflict), operations return aResultthat is either success or a failure carrying one or moreErrors.ErrorTypeis the classification axis of that pattern. This also touches[Rubric §9, API & Contract Design](assesses consistent, standardized error responses): the enum's doc comment (ErrorType.cs:4-7) states the first error in a result determines the response status, andApiControllerBasemaps eachErrorTypeto a status code via aFrozenDictionary, so error shapes are uniform across every endpoint. And[Rubric §3, Clean Architecture](dependencies point inward; the domain is framework-free): note this HTTP-shaped concept lives inSharedas a pure enum with no reference to ASP.NET, the HTTP mapping happens later, in the API layer. The Result pattern is the single most pervasive idiom in the codebase (see primer §2). - Walkthrough: eight members, each documented with its target status (
ErrorType.cs:11-32):Validation(400),Invariant(400, a broken business rule),NotFound(404),Conflict(409, e.g. duplicate or already-deleted),Unauthorized(401),Forbidden(403),UnprocessableEntity(422, e.g. an attempt to change an immutable field), andFailure(400, the catch-all). The values are not given explicit numbers, ordinal order is irrelevant because the HTTP mapping is keyed by name, not by integer value. - Why it's built this way: separating classification (this enum) from carrier (
Error) from outcome (Result) keeps each piece tiny and lets the same eight categories drive both domain logic and HTTP translation from one source of truth. - Where it's used: pervasively:
Error's factory methods each hard-code the correctErrorType;ApiControllerBase.HandleFailure(API layer) switches on it;ErrorHttpMappingholds theErrorType → statustable. - Caveats / not-in-source: the exact
ErrorType → statustable lives inApiControllerBase/ErrorHttpMapping(a higher tier, group G12); only the intent is documented here.
PaginationMetadata
MMCA.Common.Shared ·
MMCA.Common.Shared.Abstractions·MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/PaginationMetadata.cs:12· Level 0 · record (sealed)
- What it is: an immutable record carrying server-side pagination state (total items, page size, current page) with three derived, non-serialized convenience properties.
- Depends on: nothing first-party. (Lives in the same file as
CollectionResult<T>andPagedCollectionResult<T>.) - Concept introduced, server-side pagination + the
[DataContract]serialization contract.[Rubric §9, API & Contract Design](uniform pagination/filtering conventions) and[Rubric §12, Performance & Scalability](assesses query efficiency and paginating large result sets rather than returning whole tables). Carrying explicit pagination metadata is what lets the API page at the database and tell the client how to navigate. - Walkthrough
- Two constructors: a parameterless one delegating to the main ctor with zeros (
PaginationMetadata.cs:15-16), and the main ctor (lines 22-31) which guards every argument withArgumentOutOfRangeException.ThrowIfNegative, invalid pagination can't be represented. - Three stored values:
TotalItemCount,PageSize,CurrentPage, allinitand tagged[DataMember(Order = …)](lines 34-43) for deterministic serialization order. - Three computed properties tagged
[IgnoreDataMember]so they're not serialized (they're derivable):TotalPageCount(ceiling division, line 47),FirstRowOnPage(line 51), andLastRowOnPage(clamped to the total, line 55). Note the(long)casts in the row math, a deliberate guard againstintoverflow on large datasets.
- Two constructors: a parameterless one delegating to the main ctor with zeros (
- Why it's built this way:
[DataContract]/[DataMember]/[IgnoreDataMember]make the wire shape explicit and stable (only the three core values travel; the rest are recomputed client-side), which is exactly the "stable, evolvable contract" §9 asks for. Validation in the constructor means an invalid instance is unconstructable, a recurring theme (compare value-object factories in group G02). - Where it's used: embedded in
PagedCollectionResult<T>(Level 1) and returned by paged query endpoints throughout both apps.
PropertyReader
MMCA.Common.Shared ·
MMCA.Common.Shared.Serialization·MMCA.Common/Source/Core/MMCA.Common.Shared/Serialization/ResultJsonConverterFactory.cs:95· Level 0 · delegate
- What it is: a small private delegate that
ResultJsonConverterFactoryuses internally to hand each property of aResultJSON payload to a per-converter callback while one shared object-walker drives the reader. - Depends on:
System.Text.Json.Utf8JsonReader(BCL, passed byref). - Concept, a
ref struct-friendly callback shape.[Rubric §15, Best Practices & Code Quality](assesses DRY, no duplicated parsing loop). The two nested converters (ResultConverterand its generic sibling) differ only in which JSON properties they care about, so the property loop is factored out intoReadObject, and this delegate is howReadObjectcalls back into each converter. BecauseUtf8JsonReaderis aref struct(it cannot be captured by an ordinaryFunc<>), the delegate takes the reader byref(ResultJsonConverterFactory.cs:95) so the callback advances the same reader instance. - Walkthrough: a one-line declaration (
ResultJsonConverterFactory.cs:95):void (ref Utf8JsonReader reader, string propertyName).ReadObject(line 98) positions the reader on each property value and then invokes aPropertyReaderonce per property (line 117). - Why it's built this way: keeping the token-stream bookkeeping (StartObject check, EndObject termination, truncation guards) in one
ReadObjectmethod and passing the "what to do with this property" logic as a delegate avoids copy-pasting the JSON walk into both converters. - Where it's used: consumed only inside
ResultJsonConverterFactory;ResultConverter.Read(line 41) andResultConverter<T>.Read(line 67) each pass a lambda of this shape. - Caveats / not-in-source: it is
private, so it is not part of any public API; it exists purely to support the converter factory.
DomainInvariantViolationException
MMCA.Common.Shared ·
MMCA.Common.Shared.Exceptions·MMCA.Common/Source/Core/MMCA.Common.Shared/Exceptions/DomainInvariantViolationException.cs:9· Level 1 · class
- What it is: the concrete exception thrown when a domain invariant is violated in a context where the
Resultpattern cannot be used (e.g. inside aggregate constructors called by EF Core materialization, where the call stack is framework-owned and returning aResultis impossible). - Depends on:
DomainException(Level 0). - Concept: this is the safety valve the doc comment (
DomainInvariantViolationException.cs:3-8) spells out explicitly: "prefer returningResultwithError.Invariantfor normal business rule violations".[Rubric §2, Design Patterns](§2 flags exceptions used as control flow; this type is the sanctioned last resort, not the default path).DomainExceptionHandlermiddleware (API layer, group G12) catches anyDomainExceptionand maps it to HTTP 400 + ProblemDetails. - Walkthrough: three standard
publicconstructors delegating toDomainException(DomainInvariantViolationException.cs:12-24): parameterless, message, and message+inner. The class ispublicand notsealed, consumers can subclass it if they need more specific exception types. - Where it's used: thrown inside aggregate factory methods or invariant-check helpers (group G02) where a broken invariant indicates corrupted persistent state rather than a recoverable user error.
- Caveats / not-in-source: earlier editions of this guide tagged the class
sealedand its constructorsprotected; the current source (lines 9, 12-24) shows neither, the class is a non-sealedpublictype with threepublicconstructors. Trust the source.
Error
MMCA.Common.Shared ·
MMCA.Common.Shared.Abstractions·MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Error.cs:15· Level 1 · record
- What it is: the immutable error value carried by
Result. Every error has a machine-readableCode, a human-readableMessage, anErrorTypethat maps to an HTTP status code, and optionalSource/Targetmetadata. - Depends on:
ErrorType(Level 0). - Concept introduced, the Error carrier.
[Rubric §2, Design Patterns](assesses idiomatic patterns that solve real problems): anErroris a value, not an exception. It can be created, stored in a list, passed through a pipeline, and inspected without stack-unwinding cost.[Rubric §9, API & Contract Design](assesses consistent, standardized error responses): the combination ofCode(machine-readable, for programmatic branching) +Message(human-readable, for clients) +ErrorType(HTTP status selector) gives every API endpoint a uniform error shape without any controller having to decide how to format errors. - Walkthrough
- The type is a positional
record(Error.cs:15-20):Code,Message,Type,Source?,Target?. Records give value equality, twoErrorinstances with the same fields are equal. - Three pre-built
static readonlysingletons (Error.cs:23-29) for the most common cases:Error.NotFound,Error.AlreadyDeleted,Error.InvalidEntityField. These avoid repeatednew Error(...)call sites for the ubiquitous cases. - Eight factory methods, one per
ErrorType(Error.cs:37-101):Validation,Invariant,NotFoundError,Conflict,Unauthorized,Forbidden,UnprocessableEntity,Failure. Each constructs anErrorwith the correctErrorTypepre-set, callers can't accidentally mismatch the factory and the type. The naming convention is deliberate:Validation(code, message)reads naturally at the call site. (Note the one renamed factory: theNotFoundstatic field and theNotFoundErrorfactory method differ because C# forbids a method and field sharing a name.) - Two
with-expression helpers (Error.cs:106-113):WithSource(string)andWithTarget(string)return a new record copy with one field replaced, useful for enriching a library-level error with caller context without mutating it. Records make this a one-liner:this with { Source = source }.
- The type is a positional
- Why it's built this way: keeping
Erroras an immutable value (not an exception) aligns with the Result pattern's goal: represent expected failures as data, not as control flow. Pre-built singletons avoid allocations on hot paths. Factory methods enforce theCode → ErrorTypepairing at the source so no mapping table is needed elsewhere. - Where it's used: every command/query handler returns
Result<T>carrying zero or moreErrorvalues;ApiControllerBase.HandleFailureinspects the first error'sTypeto pick the HTTP status (theErrorType → statusmapping lives there, not here);ValidationFailureExtensions(group G06) converts FluentValidation failures toError.Validation(...)instances. Also serialized on the wire byResultJsonConverterFactoryas theerrorsarray.
PagedCollectionResult<T>
MMCA.Common.Shared ·
MMCA.Common.Shared.Abstractions·MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/PaginationMetadata.cs:91· Level 1 · record (sealed)
- What it is: a
CollectionResult<T>augmented with aPaginationMetadataproperty, the standard shape for paged API responses. - Depends on:
CollectionResult<T>(Level 0),PaginationMetadata(Level 0). - Concept: same envelope pattern as
CollectionResult<T>but extended with pagination metadata in a single, orthogonal step.[Rubric §12, Performance & Scalability](paging large result sets) and[Rubric §9, API & Contract Design](stable, evolvable contract). Thesealed recordinheritsCollectionResult<T>and adds one requiredPaginationMetadataproperty; the[DataContract]/[DataMember(Order = 2)]on it (lines 90, 110-111) ensures pagination state serializes afterItemsin a deterministic wire order. - Walkthrough: two constructors mirroring the base: a parameterless one producing an empty result with default metadata (lines 95-96) and a data constructor (lines 102-107) that calls
base(items)and null-checkspaginationMetadataviaArgumentNullException.ThrowIfNull. Both are[SetsRequiredMembers](lines 94, 101). Therequiredmodifier onPaginationMetadata(line 111) forces it to be set at construction so it's never null at runtime. - Where it's used: returned by every paged query handler (e.g.
GetCategories,GetSessionsin ADC); the entity query service (group G03) always wraps its paged results in this type.
Result
MMCA.Common.Shared ·
MMCA.Common.Shared.Abstractions·MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/Result.cs:18· Level 2 · class
What it is: the non-generic railway-oriented result type: either a success (no errors) or a failure carrying one or more
Errorinstances. The non-generic form is used for void-equivalent operations (invariant checks, deletes, commands with no return value). The genericResult<T>(same file, line 119) carries a value on success.Depends on:
Error(Level 1);ResultJsonConverterFactory(attached to bothResultandResult<T>via[JsonConverter],Result.cs:17,118);System.Collections.Generic(BCL).Concept introduced, railway-oriented programming and
Resultcombinators.[Rubric §2, Design Patterns](assesses idiomatic, problem-solving patterns; the Result pattern is the codebase's canonical error-flow mechanism, not an exception crutch).[Rubric §9, API & Contract Design](consistent, structured error shapes at every endpoint).[Rubric §12, Performance & Scalability]shows up in the deliberate allocation-avoidance choices called out below.The railway metaphor: picture two tracks, a success track and a failure track. Each operation takes the current result as input. If it is already a failure the operation is skipped; if it is a success the operation runs and may produce a new success or switch to the failure track. Control flows forward without
try/catchnesting.ErrorType(Level 0) classifies the failure;Error(Level 1) carries the coded message;Resultis the outcome envelope. Together they are the three-layer split introduced withErrorType.The non-generic
Result:- A shared success instance
CachedSuccess(line 20) and an emptyNoErrorsarray (line 21) back the two cheapest paths. Errors live in a lazily-allocatedList<Error>? _errors(line 25): as the field comment notes (lines 23-24), the success path (the overwhelming majority of results created per request) never pays for a list allocation. Errorsexposes the list asIReadOnlyList<Error>, falling back to the sharedNoErrorsarray when the field is null (line 28);IsSuccessis_errors is null || _errors.Count == 0(line 31) andIsFailureis its negation (line 34).Result.Success()(line 43) returns the sharedCachedSuccesssingleton (success results carry no per-instance state);Result.Success<T>(value)(line 49) wraps a value in a newResult<T>.Result.Failure(Error)(line 70) andResult.Failure(IEnumerable<Error>)(line 60), plus their<T>overloads (lines 55, 77), are the failure factories.Result.Combine(params ReadOnlySpan<Result> results)(line 87), the aggregate-all-failures combinator: runs every check, collects all errors into one failure, and returns success only when all inputs succeed. TheReadOnlySpan<Result>parameter avoids a heap allocation at the common call site, and theallErrorslist is lazily allocated only when a failure is actually seen (line 100). The guard at lines 89-91 throwsArgumentExceptionfor an empty span (combining nothing is a logic bug). Called constantly in entity factory methods:Result.Combine(CheckName(name), CheckDate(date), …).
The generic
Result<T>(line 119):sealed classinheritingResult, addsT? Value(line 122),nullwhenIsFailure.- Two
internalconstructors (lines 126, 130) so only the base-class factory methods can produce instances, consumers can never directlynew Result<Order>(…). Match<TResult>(onSuccess, onFailure)(line 141), exhaustive pattern match; exactly one branch runs (both delegates null-checked, lines 143-144). Avoidsif (result.IsFailure)everywhere:return result.Match(v => Ok(v), errs => Failure(errs)).Map<TOut>(mapper)(line 157), transforms the success value, propagating errors unchanged. Allows pipelining:result.Map(order => order.ToDto())stays on the success track without anifcheck.BindAsync<TOut>(binder)(line 170), monadic bind for async continuations. If the current result is a failure it short-circuits and returns the original errors without callingbinder; otherwise it awaits the next async operation (which itself returnsResult<TOut>), withConfigureAwait(false). This is the core of async railway chains:await result.BindAsync(order => PlaceOrderAsync(order)).
- A shared success instance
Why it's built this way: see
ErrorTypefor the broader rationale. The three-method combinator set (Match/Map/BindAsync) was deliberately kept minimal: they cover the three real usage shapes (terminate, transform, chain).Combinesolves the aggregate-validation problem cleanly without forcing callers to interleave checks. Keeping the value-bearing constructorsinternalchannels every construction through the static factories, so the success/failure invariant (aResult<T>with a value is never also a failure) holds by construction. The sharedCachedSuccess/NoErrorsinstances and the lazily-allocated error list keep the overwhelmingly-common success path allocation-free.Where it's used: pervasively: every entity factory method (group G02), every command/query handler (group G05), every controller action (group G12), and every UI service method in both apps returns
ResultorResult<T>. It is made round-trippable over the wire and through the distributed cache byResultJsonConverterFactory. Rubric categories§2,§4,§9all touch it across higher groups.
ResultConverter
MMCA.Common.Shared ·
MMCA.Common.Shared.Serialization·MMCA.Common/Source/Core/MMCA.Common.Shared/Serialization/ResultJsonConverterFactory.cs:35· Level 2 · class (private sealed, nested)
- What it is: the private nested
System.Text.Jsonconverter for the non-genericResult. A structurally identical generic sibling,ResultConverter<T>(ResultJsonConverterFactory.cs:60), handlesResult<T>and additionally round-trips the successValue. - Depends on:
Result,Error, the outerResultJsonConverterFactory(which owns, creates, and supplies the shared helpers),PropertyReader; BCLSystem.Text.Json. - Concept introduced, custom JSON round-tripping of factory-constructed types.
[Rubric §12, Performance & Scalability](the distributed query cache serializes cached handler results to Redis, soResultmust be reconstructable) and[Rubric §9, API & Contract Design](a stable, compact wire shape for results). BecauseResult/Result<T>keepinternalconstructors and get-only properties (seeResult), default reflection-based deserialization cannot rebuild them; this converter reads the compact{"value": ..., "errors": [...]}shape and reconstructs through the public factory methods, preserving the success/failure invariant that direct field-setting would bypass. - Walkthrough
Read(line 37): declaresList<Error>? errors, then calls the sharedReadObjectwith aPropertyReaderlambda that deserializes theerrorsarray on a case-insensitive property match (line 43) and skips every other property (line 46). It returnsResult.Failure(errors)when any errors were present, elseResult.Success()(line 49).Write(line 52): opens a JSON object and delegates to the sharedWriteErrorshelper (line 55), which emits theerrorsarray only for a failure (lines 123-129).- The generic
ResultConverter<T>(line 60) mirrors this but also handles thevalueproperty:Read(line 62) capturesvalueand reconstructs viaResult.Success(value!)/Result.Failure<T>(errors)(line 77);Write(line 80) writesvalueonly when the resultIsSuccess(lines 84-88).
- Why it's built this way: reconstructing through
Result.Failure/Result.Successrather than setting fields keeps a deserialized result honest (a value only on success, errors only on failure). Writingvalueonly on success anderrorsonly on failure keeps the payload minimal. Nesting the converter privately inside the factory keeps it an implementation detail no consumer needs to reference. - Where it's used: instantiated by
ResultJsonConverterFactory.CreateConverter(lines 28-32); never referenced directly by application code.
ResultJsonConverterFactory
MMCA.Common.Shared ·
MMCA.Common.Shared.Serialization·MMCA.Common/Source/Core/MMCA.Common.Shared/Serialization/ResultJsonConverterFactory.cs:15· Level 2 · class (sealed)
- What it is: a
JsonConverterFactorythat produces the correct converter forResultor any closedResult<T>; it is wired onto both types by the[JsonConverter(typeof(ResultJsonConverterFactory))]attribute (Result.cs:17,118). - Depends on:
Result,ResultConverter(and its generic sibling),PropertyReader; BCLSystem.Text.Json(JsonConverterFactory,Utf8JsonReader,Activator). - Concept introduced, the STJ converter-factory for an open generic.
[Rubric §12, Performance & Scalability]and[Rubric §9, API & Contract Design]. The doc comment (ResultJsonConverterFactory.cs:7-14) states the rationale plainly: the Result types deliberately keep internal constructors and get-only properties, so a single factory keyed on the open generic makes everyResult<AnyT>round-trippable (as required by the Redis-backed distributed query cache) without registering one converter perT. - Walkthrough
- Two private constants
ValuePropertyName = "value"andErrorsPropertyName = "errors"(lines 17-18) define the wire shape used by both converters. CanConvert(line 21): returns true fortypeof(Result)or any generic type whose definition isResult<>.CreateConverter(line 26): returns aResultConverterfor the non-generic case (line 29); otherwise it reflects the value type and constructsResultConverter<T>viaActivator.CreateInstance(typeof(ResultConverter<>).MakeGenericType(valueType))(lines 31-32).ReadObject(line 98): the shared object-walker that drives aPropertyReader. It requires aStartObjecttoken (lines 102-103), returns onEndObject(lines 107-108), positions the reader on each property value, and throwsJsonExceptionon a malformed or truncated payload (lines 110-120).WriteErrors(line 123): the shared writer that emits theerrorsarray only when the result is a failure.
- Two private constants
- Why it's built this way: one factory keyed on the open generic covers
Result<AnyT>with no per-Tregistration; centralizing the token bookkeeping inReadObject/WriteErrorsand the callback shape inPropertyReaderkeeps the two concrete converters tiny and identical in structure. - Where it's used: attached to
ResultandResult<T>via the type-level[JsonConverter]attribute;System.Text.Jsoninvokes it automatically wherever aResultis (de)serialized, most notably on the distributed-cache serialization path.
⬅ Index • Index • Domain Building Blocks (Entities, Value Objects, Aggregates) ➡