Onboarding guide
2. Domain Building Blocks (Entities, Value Objects, Aggregates)
What this group covers. This is the DDD heart of the framework, the small, dependency-light
primitives every business model in MMCA.Common and MMCA.ADC is built from. There are three
families here, and they interlock:
- The entity hierarchy, a three-rung inheritance chain (
BaseEntity<TIdentifierType>→AuditableBaseEntity<TIdentifierType>→AuditableAggregateRootEntity<TIdentifierType>) plus the contracts that describe each rung (IBaseEntity<TIdentifierType>,IAuditableEntity,IRowVersioned,IAggregateRoot). Each rung adds exactly one capability: identity, then audit/soft-delete/concurrency, then domain-event collection and aggregate operations. - The value-object family, the
ValueObjectbase and the concrete, immutable concepts built on it:Address,Money,Currency,Email,PhoneNumber,DateRange, andDateTimeRange, each guarded by a matching invariants helper (AddressInvariants,EmailInvariants,PhoneNumberInvariants) plus the sharedCommonInvariantstoolbox, and theCurrencyJsonConverterthat putsCurrencyon the wire. - The governance markers and helpers, the attributes and small utilities that drive
metadata-based behavior across the stack:
PiiAttribute(erasure and log masking) with its redaction halfPiiRedactorand that helper's cachedRedactablePropertydescriptor,IdValueGeneratedAttribute+EntityTypeExtensions(database-generated IDs),IAnonymizable(GDPR/CCPA erasure), theDomainEntityStateenum (state-change classification for domain events), andDomainHelper(culture-invariant identifier parsing).
All of these live in the two innermost layers, MMCA.Common.Shared (value objects, invariants,
DomainHelper) and MMCA.Common.Domain (entities, interfaces, attributes, enums, privacy helpers), so
the whole group sits below Application and Infrastructure in the dependency flow (see
primer §1). Nothing here references EF Core, ASP.NET, or a message
broker; persistence and dispatch are described by these types and implemented by higher groups.
That separation is the [Rubric §3, Clean Architecture] and [Rubric §4, Domain-Driven Design] story
in miniature: the model is framework-free, and the framework adapts to it.
The entity chain, one capability per rung
Read the chain bottom-up. BaseEntity<TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/BaseEntity.cs:14) is almost nothing: a single
required init identifier of the per-entity alias type, constrained where TIdentifierType : notnull
(BaseEntity.cs:15-17), and it implements
IBaseEntity<TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IBaseEntity.cs:7), which declares Id with an
init accessor so the contract itself forbids reassignment (IBaseEntity.cs:11). See
identifier aliases for where the alias
types come from. required init is the load-bearing choice: a factory method sets Id once at
construction and it is immutable thereafter, while EF Core still materializes the entity through the
parameterless constructor.
AuditableBaseEntity<TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:13) adds the
cross-cutting facts every persisted row needs: soft-delete (IsDeleted at
AuditableBaseEntity.cs:20, plus a Delete()/Undelete() pair at AuditableBaseEntity.cs:47 and
AuditableBaseEntity.cs:67 that return Result and refuse
to double-delete or to undelete a live row), audit fields (CreatedOn/By, LastModifiedOn/By at
AuditableBaseEntity.cs:25-31) with private setters, and the RowVersion optimistic-concurrency
token (AuditableBaseEntity.cs:39). The domain never writes the audit fields: they are stamped
centrally by AuditSaveChangesInterceptor,
which walks ChangeTracker.Entries<IAuditableEntity>() and assigns through
entry.Property(...).CurrentValue
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:43-57),
freezing CreatedOn/By as unmodified on updates. The class declares both
IAuditableEntity and IRowVersioned
(AuditableBaseEntity.cs:13); the latter exists so a repository can accept any tracked child entity
for a concurrency check without a second generic parameter for the child's identifier type
(MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IRowVersioned.cs:11, rationale in ADR-035).
This rung is where [Rubric §8, Data Architecture] (soft-delete, audit, concurrency) meets [Rubric §10,
Cross-Cutting]: three concerns that would otherwise be copy-pasted into every entity are inherited once
and enforced centrally (ADR-005 for soft-delete versus erasure).
AuditableAggregateRootEntity<TIdentifierType>
(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:13) is the top
rung and the one that earns the DDD name "aggregate root". It implements
IAggregateRoot, so it owns a private domain-event list with AddDomainEvent,
ClearDomainEvents, and a read-only DomainEvents view (AuditableAggregateRootEntity.cs:16-34), and
it adds the two helpers that let a root police its own consistency boundary: SetItems<TChildEntity>
(replace a child collection, routed through an overridable ValidateSetItems hook so a root can veto,
say, removing a shipped order line, AuditableAggregateRootEntity.cs:44-74) and
GetChildOrNotFound<TChild, TChildId> (find an active, non-soft-deleted child by id or return an
Error.NotFound failure,
AuditableAggregateRootEntity.cs:87-104). Only aggregate roots raise domain events, and that is how
the persistence layer knows where to look. This rung is the clearest [Rubric §4, Domain-Driven Design]
and [Rubric §6, CQRS & Event-Driven] expression in the codebase: invariants are enforced inside the
boundary, and state changes are announced as events rather than leaked as side effects.
How a domain event leaves an aggregate
The runtime flow ties this group to the events/outbox group. A command handler loads an aggregate,
calls a business method, and that method calls AddDomainEvent(...); the event sits in the aggregate's
private list, doing nothing yet. On save, EF Core interceptors take over.
DomainEventSaveChangesInterceptor
captures every tracked IAggregateRoot via
context.ChangeTracker.Entries<IAggregateRoot>()
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:145),
serializes the pending IDomainEvents into
OutboxMessage rows in the same transaction as the data,
then dispatches the local events in process and calls ClearDomainEvents() on each captured aggregate
so nothing is delivered twice (DomainEventSaveChangesInterceptor.cs:229-257). Inside a transactional
command the dispatch is deferred until after commit and re-queued through a DeferredDispatch record
(DomainEventSaveChangesInterceptor.cs:212-213), so a handler never acts on state that could still roll
back. The DomainEntityState enum (Unchanged/Added/Updated/Deleted, with
explicit numeric values at
MMCA.Common/Source/Core/MMCA.Common.Domain/Enums/DomainEntityState.cs:9-12) is the small vocabulary an
event uses to say what kind of change happened. The aggregate base is the producer end of the
at-least-once outbox pipeline (ADR-003); the consumer end lives in
Group 04.
Value objects, invalid instances cannot exist
The second family models concepts with no identity: two Money(10, USD) are equal because their
values match, not because they are the same row. ValueObject is the cheapest possible
base, public abstract record ValueObject;
(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/ValueObject.cs:8), so every value object
inherits compiler-generated structural equality and immutability for free (the canonical Value Object
teaching is in primer §2).
The shared shape across all of them is the private-constructor + static Create factory returning
Result<T> idiom: you cannot new a value object, and
the only way in runs through validation, so an invalid Email, Money, Address, or DateRange
simply cannot be constructed. The validation logic itself is factored out into static invariants
classes, AddressInvariants
(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/AddressInvariants.cs:9),
EmailInvariants
(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/EmailInvariants.cs:11), and
PhoneNumberInvariants
(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/PhoneNumberInvariants.cs:11), which also
publish the length constants that EF entity configurations and FluentValidation validators reuse
(Email at 256 characters, EmailInvariants.cs:14; PhoneNumber between 7 and 20,
PhoneNumberInvariants.cs:14-17; the six address field limits at AddressInvariants.cs:12-27), so the
field-length rules have one source of truth. CommonInvariants
(MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:10) is the reusable lower
layer that module-specific invariants delegate to: EnsureStringIsNotEmpty, EnsureStringMaxLength,
EnsureIdIsNotDefault<TId>, and EnsureBytesAreNotEmpty (CommonInvariants.cs:21-70). Each returns a
Result, and the calling invariants class folds them together with Result.Combine so one call reports
every broken rule at once (AddressInvariants.cs:40). This whole family is the [Rubric
§4, Domain-Driven Design] and [Rubric §1, SOLID] (the factory enforces invariants; invariants are a
single-responsibility unit) story.
The concrete value objects split into a few patterns worth knowing up front:
- Owned-type composites:
Address(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Address.cs:16) andMoney(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Money.cs:18) are stored by EF asOwnsOnenested columns; both carry[DataContract]with ordered[DataMember(Order = n)]properties to pin the serialization shape (Address.cs:15-40,Money.cs:17-32).Addressrequires onlyAddressLine1and leaves the other five fields optional for international formats.Moneyis the richest: it pairs adecimal Amountwith aCurrency, defines+and*operators and aResult-returningAdd(Money.cs:68-102), and treatsCurrency.Noneas an additive identity soMoney.Zero()works as an accumulator seed regardless of the eventual currency (Money.cs:115-126). Note the asymmetry worth remembering: the+operator throwsInvalidOperationExceptionon a currency mismatch (Money.cs:73) whileAddreturns aCurrencyMismatchfailure, so preferAddin domain code. - Closed enumeration:
Currency(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Currency.cs:14) is a record with a private constructor and a fixedAllset of exactlyUsdandEur(Currency.cs:54-58), plus aninternalNonesentinel that is deliberately not inAlland never reaches API consumers (Currency.cs:23).FromCodeis the only public way to get one and matches case-insensitively (Currency.cs:41-51), andCurrencyJsonConverter(Currency.cs:65) serializes it as its bare ISO-4217 code on the wire, throwing aJsonExceptionon an unknown code when reading (Currency.cs:68-83). - Converted scalars:
Email(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Email.cs:13) andPhoneNumber(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/PhoneNumber.cs:13) are stored via EFHasConversion(notOwnsOne), so the column stays a flatnvarchar. Both normalize on creation (Emailtrims then lowercases withToLowerInvariant,Email.cs:29-36;PhoneNumbertrims,PhoneNumber.cs:33) and expose an implicitstringconversion plus aToStringoverride for ergonomics (Email.cs:42-45,PhoneNumber.cs:38-41). - Interval pairs:
DateRange(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/DateRange.cs:9,DateOnlybased) andDateTimeRange(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/DateTimeRange.cs:10, full precision) are near-identical: a validated start/end pair withOverlaps,Contains,Deconstruct, and a length/duration accessor (LengthInDaysatDateRange.cs:38,DurationatDateTimeRange.cs:39);Createrejectsend < start(DateRange.cs:30-35). Read the boundary rules carefully:Containsis inclusive on both ends whileOverlapscompares half-open (DateRange.cs:46-56).
Governance markers, metadata that other layers act on
The last family is tiny attributes and helpers that carry intent the rest of the stack reads
reflectively. PiiAttribute
(MMCA.Common/Source/Core/MMCA.Common.Domain/Attributes/PiiAttribute.cs:19) tags a property as
data-subject PII, and it is a property-only, non-inherited, single-use attribute (PiiAttribute.cs:18).
Two governance mechanisms rely on the marker. First, an architecture fitness test asserts that any
entity declaring a [Pii] property also implements IAnonymizable, so every piece of
personal data has an erasure path (PiiConventionTests, driven by the shared PiiConventionTestsBase,
at MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/PiiConventionTests.cs:13; the scan is
structurally vacuous inside the framework itself because no data-subject type lives in
MMCA.Common.Domain, and its own doc comment says so). Second, PiiRedactor
(MMCA.Common/Source/Core/MMCA.Common.Domain/Privacy/PiiRedactor.cs:24) is the redaction half: it
reflects over an object's public readable properties and replaces every [Pii] value wholesale with the
"[REDACTED]" token (PiiRedactor.cs:27, PiiRedactor.cs:42-57), offering Redact (a property map),
RedactToString (a single-line rendering, PiiRedactor.cs:65), and HasPii (a type probe,
PiiRedactor.cs:98). Its per-type reflection metadata is cached in a ConcurrentDictionary of
RedactableProperty descriptors (PiiRedactor.cs:31, PiiRedactor.cs:112-121),
and a property getter that throws is caught and rendered as "[unreadable]" so a logging call site can
never be broken by redaction (PiiRedactor.cs:129-140). Important scope note: PiiRedactor is an
opt-in helper you call, not an automatic logging pipeline. Outside its own unit and fitness tests
there is no production call site in MMCA.Common or MMCA.ADC today; the framework's stated posture is
to log scalar identifiers rather than whole entities, and to route an entity through the redactor when
one must be logged (PiiRedactor.cs:10-16).
IAnonymizable
(MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAnonymizable.cs:22) defines the erasure
contract itself: an idempotent Anonymize() returning
Result (IAnonymizable.cs:30) that an application-layer
handler invokes to overwrite personal fields in place while keeping the row for referential integrity
and audit history. Together these are the [Rubric §11, Security] and [Rubric §30,
Compliance/Privacy/Data Governance] story, and they are why soft-delete and erasure are different
mechanisms (ADR-005, cited at IAnonymizable.cs:19): soft-delete hides a row but keeps its data,
anonymize destroys the data but keeps the row.
IdValueGeneratedAttribute
(MMCA.Common/Source/Core/MMCA.Common.Domain/Attributes/IdValueGeneratedAttribute.cs:9) marks a class
whose id the database generates (SQL Server IDENTITY); factory methods consult it at runtime through
EntityTypeExtensions's IsIdValueGenerated, a C# extension(Type) member
that is a one-line GetCustomAttribute probe
(MMCA.Common/Source/Core/MMCA.Common.Domain/Extensions/EntityTypeExtensions.cs:11-20), to decide
whether to assign an explicit id or leave it default for the database to fill. Finally,
DomainHelper
(MMCA.Common/Source/Core/MMCA.Common.Shared/Extensions/DomainHelper.cs:8) is the culture-invariant
string?-to-identifier parser controllers use to turn route parameters into strongly-typed ids without
coupling to a concrete id type; it handles string, Guid, int, long, ulong, bool, and enums,
falls back to the type default on unparseable input, and throws FormatException for an unsupported
identifier type (DomainHelper.cs:21-63). Its CultureInfo.InvariantCulture parsing is also the
codebase's headline [Rubric §27, Internationalization] decision (deliberate culture-invariance where
culture would otherwise introduce bugs; see
primer §6).
Where this group sits
Everything above is consumed by the layers that follow: every module entity (for example the
Conference domain, Engagement, and Identity modules) derives from one
of the three entity base classes; the persistence group (Group 07)
maps value objects, stamps the audit fields these types declare, and applies the global soft-delete
query filter keyed off IAuditableEntity; the events/outbox group
(Group 04) drains the domain events aggregates raise; and the CQRS handlers
throughout the application return the Result values these
factories produce. Read this group as the grammar of the domain: the rest of the guide is the
sentences written in it.
DomainEntityState
MMCA.Common.Domain ·
MMCA.Common.Domain.Enums·MMCA.Common/Source/Core/MMCA.Common.Domain/Enums/DomainEntityState.cs:7· Level 0 · enum
- What it is: describes the state change that triggered a domain event:
Unchanged,Added,Updated,Deleted. - Depends on: nothing first-party.
- Concept: a small payload enum for domain events.
[Rubric §6, CQRS & Event-Driven]assesses whether events carry enough context to be acted on; when an aggregate raises an event about itself or a child, this enum communicates what kind of change happened so handlers can filter and react appropriately. - Walkthrough: four explicitly-numbered members (
DomainEntityState.cs:9-12);Unchanged = 0so the default value is the no-op state. - Why it's built this way: explicit numeric values make the enum stable across serialization (a
reordering will not change the wire meaning), relevant since these values travel inside events. The
enum also collapses what would otherwise be three near-identical event types per entity
(
Added/Updated/Deleted) into one, which is exactly the rationale recorded onEntityChangedEvent<TIdentifierType>(MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:8-12). - Where it's used: it is the
Statemember ofEntityChangedEvent<TIdentifierType>(EntityChangedEvent.cs:25), so every derived per-entity change event carries it; aggregates passDomainEntityState.Addedfrom factories,Updatedfrom mutators andDeletedfromDelete()(for exampleMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:72,95,116), and handlers short-circuit on it (for exampleMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/DomainEventHandlers/SessionCreatedHandler.cs:17).
DomainHelper
MMCA.Common.Shared ·
MMCA.Common.Shared.Extensions·MMCA.Common/Source/Core/MMCA.Common.Shared/Extensions/DomainHelper.cs:8· Level 0 · class (static)
- What it is: a static class that adds a generic
Parse<TIdentifier>()extension member tostring?, converting a route-parameter string into a strongly-typed identifier. - Depends on: BCL only (
System.Globalization). - Concept introduced, C#
extension(T)members.[Rubric §15, Best Practices & Code Quality](assesses idiomatic, modern-language use). This is the first concrete sighting of the C# preview feature described in primer §4. The blockextension(string? id) { … }(DomainHelper.cs:13) means any nullable string can callsomeId.Parse<int>(). The receiveridis the "this" value. - Walkthrough
Parse<TIdentifier>()(DomainHelper.cs:21): special-casesstring(returns the value or empty, lines 25-26), short-circuits null/whitespace todefault(lines 28-29), then delegates toParseNonEmpty(line 31).ParseNonEmpty<TIdentifier>(line 37) andParseOtherTypes<TIdentifier>(line 52): a chain oftypeof(TIdentifier) == typeof(Guid|int|long|ulong|bool)plustype.IsEnumchecks (lines 40-61) using culture-invariantTryParse; an unsupported type throwsFormatException(line 63). Each failedTryParsefalls back to the type's zero/empty value rather than throwing. Splitting into two private methods keeps each within the analyzers' cyclomatic-complexity budget.- Note the
#pragma warning disable IDE0051(lines 36-38) aroundParseNonEmptywith a comment (line 35) explaining it is a false positive: the analyzer cannot see that the method is called from inside theextensionblock. A justified, scoped suppression.
- Why it's built this way: controllers receive ids as
stringroute values; this converts them to the entity's id alias type without the controller coupling to a specific id type, generic overTIdentifier. Culture-invariant parsing avoids locale-dependent bugs and is one of the few places §27 (i18n) bites, see primer §6. - Where it's used: controller base classes converting route parameters to typed ids (API layer, G12).
- Caveats / not-in-source: supported target types are exactly those enumerated; anything else
throws at runtime (there is no compile-time constraint preventing an unsupported
TIdentifier).
IAuditableEntity
MMCA.Common.Domain ·
MMCA.Common.Domain.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAuditableEntity.cs:8· Level 0 · interface
- What it is: the contract for entities that support soft-delete and audit tracking:
IsDeleted,CreatedOn/By,LastModifiedOn/By. - Depends on: nothing first-party (uses the
UserIdentifierTypealias). - Concept introduced, soft-delete + centralized audit.
[Rubric §8, Data Architecture](assesses soft-delete + global query filters and audit fields stamped centrally, not per-handler). Entities are never hard-deleted;IsDeleted(IAuditableEntity.cs:11) flips totrueand EF global query filters hide the row. The audit fields (CreatedOnline 14,CreatedByline 17,LastModifiedOn?line 20,LastModifiedBy?line 23) are read-only from the domain's view, the doc comment (lines 4-7) states infrastructure populates them inSaveChangesAsyncvia EF'sChangeTracker. So the domain declares the audit contract; the stamping happens centrally in one interceptor (AuditSaveChangesInterceptor). This is also[Rubric §30, Compliance, Privacy & Data Governance](an audit trail supports accountability) and ties to ADR-005 (soft-delete vs. erasure). - Walkthrough: five getter-only properties.
CreatedByisUserIdentifierType;LastModifiedByisUserIdentifierType?(nullable, null until first modified, matchingLastModifiedOn?). No setters at all: the domain can read audit state but only infrastructure writes it. - Why it's built this way: making audit a contract (not a base-class detail) lets the EF
interceptor recognize "any
IAuditableEntity" and stamp it uniformly; centralizing it is exactly the cross-cutting discipline §8/§10 reward. The identifier alias keeps "who" strongly named. - Where it's used: implemented by
AuditableBaseEntity<TIdentifierType>(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:13, with private setters populated by EF, lines 20-31); recognized by the auditSaveChangesinterceptor and the soft-delete query filter (G07). ItsIsDeletedflag is the counterpart thatIAnonymizabledeliberately does not satisfy on its own (see ADR-005, and the explicit statement of that gap inIAnonymizable.cs:11-13).
IBaseEntity<TIdentifierType>
MMCA.Common.Domain ·
MMCA.Common.Domain.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IBaseEntity.cs:7· Level 0 · interface
- What it is: the base contract for every domain entity: a single strongly-typed, immutable identifier.
- Depends on: nothing first-party.
- Concept introduced, entity identity.
[Rubric §4, DDD](assesses aggregates/entities with clear identity). An entity (unlike a value object) has identity, it is the same thing across changes because itsIdis the same. This interface is the minimal expression of that:TIdentifierType Id { get; init; }withwhere TIdentifierType : notnull(IBaseEntity.cs:7-11). - Walkthrough: one
initproperty.init(set at construction, immutable after) encodes "an entity's identity is assigned once and never changes", the doc comment (IBaseEntity.cs:10) says exactly this. - Why it's built this way: generic id type so each entity binds its strong-id alias; the contract
is intentionally tiny so the concrete base classes
(
BaseEntity<TIdentifierType>→AuditableBaseEntity<TIdentifierType>→AuditableAggregateRootEntity<TIdentifierType>) can layer behavior on top. - Where it's used: implemented (indirectly) by every entity in both apps via the
BaseEntity<TIdentifierType>hierarchy; the parallel DTO contract isIBaseDTO<TIdentifierType>.
IdValueGeneratedAttribute
MMCA.Common.Domain ·
MMCA.Common.Domain.Attributes·MMCA.Common/Source/Core/MMCA.Common.Domain/Attributes/IdValueGeneratedAttribute.cs:9· Level 0 · class (sealed attribute)
- What it is: marks an entity whose
Idis generated by the database (for example SQL ServerIDENTITY) rather than assigned by the application. - Depends on:
System.Attribute(BCL) only. - Concept introduced, attribute-driven behavior in the domain.
[Rubric §8, Data Architecture](deliberate key-generation strategy). A factory method needs to know whether to assign an explicitIdor leave itdefaultfor the database to fill. Rather than hard-code that per entity, the decision is declared with this attribute and read reflectively at runtime (EntityTypeExtensions.IsIdValueGenerated). The doc comment (IdValueGeneratedAttribute.cs:3-7) describes exactly this.[Rubric §3, Clean Architecture]: this is a domain-level attribute (no EF reference), so the key-generation policy lives with the entity, not in infrastructure. - Walkthrough:
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)](line 8); the attribute body is empty (sealed class IdValueGeneratedAttribute : Attribute;, line 9), it is a pure marker. - Why it's built this way:
Inherited = falsemeans a subclass does not silently inherit database-generated semantics; the marker keeps key-generation policy declarative and co-located with the entity. - Where it's used: read by
EntityTypeExtensions(Level 1) and by entity factory methods deciding whether to setId.
IRowVersioned
MMCA.Common.Domain ·
MMCA.Common.Domain.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IRowVersioned.cs:11· Level 0 · interface
- What it is: a one-member contract for any entity that carries a database-managed
optimistic-concurrency token, exposing
byte[] RowVersion(IRowVersioned.cs:15). - Depends on: nothing first-party; the property type is BCL
byte[], EF Core's nativerowversionshape. - Concept introduced, optimistic concurrency as an entity-shape contract.
[Rubric §8, Data Architecture](assesses concurrency control on writes) and[Rubric §9, API & Contract Design](assesses how a stale-write conflict is surfaced to a client). Optimistic concurrency means the database does not lock a row while a user edits it; instead every row carries a version token, the client sends back the token it last read, and theUPDATEincludes it in theWHEREclause. If someone else changed the row in between, zero rows match, EF Core raisesDbUpdateConcurrencyException, and the API maps that to409 Conflict(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/IRepository.cs:166-169). The interesting design point is why the token needs its own interface at all: the repository's aggregate-typed overloadSetOriginalRowVersion(TEntity, byte[]?)(IRepository.cs:173) can only reach the aggregate root, becauseTEntityis the root type. A child entity edit (aProductVariantunder aProduct) would otherwise need a second generic parameter for the child's own identifier type.IRowVersionederases that identifier type: the child overload (IRepository.cs:185) accepts anyIRowVersioned, so child-level edits get the same stale-token protection as the root. The doc comment states this rationale and cites ADR-035 (IRowVersioned.cs:3-10). - Walkthrough: one getter,
byte[] RowVersion(IRowVersioned.cs:15), wrapped in a scoped#pragma warning disable CA1819(lines 14-16) with the justification thatbyte[]is EF Core's native rowversion shape and mirrorsAuditableBaseEntity.RowVersion. The interface is getter-only: the domain never assigns the token, the database does. - Why it's built this way: an identifier-type-free contract is the smallest change that lets one
repository method serve both roots and children; the alternative (a second generic parameter, or a
non-generic
objectoverload) would either leak type parameters through the whole repository surface or lose type safety. ADR-035 records the decision. - Where it's used: implemented by
AuditableBaseEntity<TIdentifierType>(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:13), whoseRowVersionproperty is a private-setbyte[]defaulting to[](AuditableBaseEntity.cs:39), so every auditable entity (aggregate roots and their children) satisfies it. Consumed byIRepository<TEntity, TIdentifierType>(IRepository.cs:185) and implemented inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:84-93, which casts the child toobject, walks to_context.Entry(...).Property(nameof(AuditableBaseEntity<>.RowVersion))and assignsOriginalValue; the decorator forwards it (.../EFRepositoryDecorator.cs:45). - Caveats / not-in-source: both
SetOriginalRowVersionoverloads are a no-op when the supplied token is null or empty (EFRepository.cs:75-76,87-88), which the doc comment attributes to legacy clients and first writes (IRepository.cs:181); a client that omits the token therefore silently loses the conflict check rather than being rejected.
PiiAttribute
MMCA.Common.Domain ·
MMCA.Common.Domain.Attributes·MMCA.Common/Source/Core/MMCA.Common.Domain/Attributes/PiiAttribute.cs:19· Level 0 · class (sealed attribute)
- What it is: marks a property as personally identifiable information belonging to a data subject.
- Depends on:
System.Attribute(BCL) only. - Concept introduced, privacy governance reconciled with soft-delete.
[Rubric §30, Compliance, Privacy & Data Governance](assesses a PII inventory, retention/erasure, and, critically, reconciling soft-delete with right-to-erasure) and[Rubric §13, Observability & Operability](keeping PII out of logs). This one tiny attribute powers two governance mechanisms, per its doc comment (PiiAttribute.cs:5-13): (1) an architecture fitness test asserts that any entity declaring a[Pii]property also implementsIAnonymizable, so every data subject's data has a real right-to-erasure path (soft-delete preserves rows, so erasure needs a separate anonymize path, the exact §30 red flag this avoids; ADR-005); and (2)PiiRedactor, the redaction half of the contract (PiiAttribute.cs:10-12), masks[Pii]-marked members with the literal[REDACTED]so an entity carrying personal data can be written to a structured log or telemetry attribute without the data subject's PII leaking in clear text. Both halves exist in source: mechanism (1) is[Rubric §34, Architecture Governance & Documentation], a rule enforced by an executable fitness function rather than prose; mechanism (2) (PiiRedactor) is a real, unit-tested helper. The redactor is not auto-wired into a logging/destructuring policy (verified by search: only the attribute's own doc comment, the redactor definition, and three test files reference it, no Serilog sink or production call site routes entities through it). So the[Rubric §13]"PII out of logs" control is available and tested but opt-in per call site, not an automatic, enforced pipeline stage. - Walkthrough:
[AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)](line 18); empty body (sealed class PiiAttribute : Attribute;, line 19). The doc comment (lines 14-16) adds important judgement: apply only to genuine data-subject PII (an account holder's email/name), not to public content that merely contains a name (for example a public conference speaker profile, whose erasure obligation flows through the linked user account), a nuance that prevents over-tagging. - Why it's built this way: marking PII declaratively at the property lets both the erasure fitness
test and
PiiRedactorfind it automatically by reflection; the alternative (a hand-maintained list of which fields are personal) drifts out of sync with the model. - Where it's used: applied to four properties of the Identity
Useraggregate:Email,FirstName,LastNameandAvatarUrl(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:21,25,29,94);UserimplementsIAnonymizable(User.cs:18). The detection lives once in the sharedMMCA.Common.Testing.Architecturepackage: theEntitiesWithPiiImplementAnonymizablerule (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Governance.cs:11) scans every Domain-layer type for a[Pii]property via theHasPiiPropertyhelper (same file, lines 48-50), which matches by attribute type name (a.GetType().Name == "PiiAttribute"), not a typedGetCustomAttribute<PiiAttribute>(), because the rule library does not reference the Domain attribute type. TheIAnonymizableside, by contrast, is matched on the full nameMMCA.Common.Domain.Interfaces.IAnonymizable(ArchitectureRules.Governance.cs:7,16) so a same-named local interface cannot satisfy the rule. Each repo then supplies a thin sealed subclass ofPiiConventionTestsBase(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/PiiConventionTestsBase.cs:7) that just passes itsIArchitectureMap:MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/PiiConventionTests.cs:13(the scan is structurally vacuous today, the framework Domain ships no data-subject type) andMMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/PiiConventionTests.cs:3. The framework closes that vacuity gap with a non-vacuous companion,PiiErasureContractFitnessTests(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/PiiErasureContractFitnessTests.cs:19), which forces a representative[Pii]-carrying sample through both halves end to end (recognized and masked byPiiRedactor, then erased idempotently viaIAnonymizable). The redaction half reads[Pii]reflectively viaIsDefined(typeof(PiiAttribute), inherit: false)(MMCA.Common/Source/Core/MMCA.Common.Domain/Privacy/PiiRedactor.cs:119).
RedactableProperty
MMCA.Common.Domain ·
MMCA.Common.Domain.Privacy·MMCA.Common/Source/Core/MMCA.Common.Domain/Privacy/PiiRedactor.cs:123· Level 0 · class (private sealed, nested)
- What it is:
PiiRedactor's private sealed nested cached-metadata helper, one entry per public readable property, capturing the name, whether the property is PII, and how to read its value. It exists only to backPiiRedactor.Cache(PiiRedactor.cs:31); it is not visible outside the redactor. - Depends on:
System.Reflection.PropertyInfo(BCL); constructed byPiiRedactor. - Walkthrough: a primary-constructor class
RedactableProperty(string name, bool isPii, PropertyInfo info)(PiiRedactor.cs:123) exposingName(PiiRedactor.cs:125), the precomputedIsPiiflag (PiiRedactor.cs:127), andRead(object target)(PiiRedactor.cs:129), which callsinfo.GetValue(target)and catchesTargetInvocationExceptionto returnUnreadableTokenrather than propagate, the inline comment noting that a throwing getter must never break a logging call site (PiiRedactor.cs:131-139). - Why it's built this way: precomputing the
IsPiiflag and holding thePropertyInfoonce per type (cached inPiiRedactor.Cache) means redaction never re-evaluates the[Pii]reflection check on the hot path, it just reads the cached flag and (for non-PII members) invokes the captured getter. - Where it's used: produced and consumed entirely within
PiiRedactor(GetProperties,PiiRedactor.cs:112-121); it has no independent consumers.
IAggregateRoot
MMCA.Common.Domain ·
MMCA.Common.Domain.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAggregateRoot.cs:9· Level 1 · interface
- What it is: the contract that marks a type as a DDD aggregate root and gives it the ability to accumulate domain events for post-persistence dispatch.
- Depends on:
IDomainEvent(Level 0). - Concept introduced, the Aggregate Root.
[Rubric §4, DDD](aggregates as the sole external-change entry point; transactional consistency boundary). An aggregate root owns a cluster of related objects (the aggregate) and is the only entity in that cluster that the rest of the system interacts with directly. DDD's rule is: "save or delete as a unit, never reference internal entities from outside". By implementingIAggregateRoot, a class declares itself as that transactional boundary. The doc comment (IAggregateRoot.cs:4-7) states the contract explicitly: aggregates are the only entities that can raise domain events and they define the transactional consistency boundary; the infrastructure layer (ApplicationDbContext) uses this interface to discover pending events across all tracked aggregates duringSaveChangesAsync, the hook that feeds the outbox pattern (ADR-003). - Walkthrough: three members (
IAggregateRoot.cs:12-19):IReadOnlyCollection<IDomainEvent> DomainEvents { get; }, the pending event queue (read-only from outside);void AddDomainEvent(IDomainEvent), called by the aggregate's own methods to record that something happened;void ClearDomainEvents(), called by infrastructure after the events have been dispatched.[Rubric §8, Data Architecture](SaveChanges flow): the sequence is aggregate mutates state → callsAddDomainEvent→ EF saves data + serializes events to the outbox in the same DB transaction →ClearDomainEvents()→ dispatcher dispatches in-process copies (for immediate reactions that do not need the outbox). - Why it's built this way: keeping the event queue behind a read-only collection plus explicit add/clear methods means only the aggregate's own behavior can raise events and only infrastructure can clear them after a successful save, preserving the at-least-once outbox contract (ADR-003).
- Where it's used: implemented by
AuditableAggregateRootEntity<TIdentifierType>, which adds the backing list and theAddDomainEvent/ClearDomainEventsimplementations; every aggregate in both apps inherits from that. Discovered by theDomainEventSaveChangesInterceptorduring persistence.
PiiRedactor
MMCA.Common.Domain ·
MMCA.Common.Domain.Privacy·MMCA.Common/Source/Core/MMCA.Common.Domain/Privacy/PiiRedactor.cs:24· Level 1 · class (static)
- What it is: a static helper that produces a log- and telemetry-safe view of any object by
masking every property marked with
PiiAttribute, replacing each PII value with the literal[REDACTED]. It is the redaction half of thePiiAttributecontract. - Depends on:
PiiAttribute(the marker it reads,PiiRedactor.cs:6,119); BCL only (System.Reflection,System.Collections.Concurrent,System.Collections.ObjectModel,System.Text,System.Globalization). - Concept introduced, value-erasing PII redaction for logs/telemetry.
[Rubric §13, Observability & Operability](assesses keeping personal data out of structured logs) and[Rubric §30, Compliance, Privacy & Data Governance](assesses a real data-minimization control, not just an intent). This is the implementation thatPiiAttribute's second mechanism refers to. The framework's logging convention is to record scalar identifiers, not whole entities; but when an aggregate that carries a data subject's personal data must be written to a structured log or a telemetry attribute, route it throughRedact/RedactToStringso the PII never leaves the process in clear text (the rationale is stated in the doc comment,PiiRedactor.cs:10-17). Masking is deliberately value-erasing rather than truncating or hashing (PiiRedactor.cs:18-23): even a value's length or hash can leak information about a data subject, so a[Pii]value is replaced wholesale withRedactedToken. This is the log-side counterpart toIAnonymizable's storage-side erasure: together they are the two halves of the §30/ADR-005 story ([Pii]says what is personal;PiiRedactorkeeps it out of logs;IAnonymizableerases it from storage). - Walkthrough
RedactedToken(PiiRedactor.cs:27): the publicconst string = "[REDACTED]"substituted for every masked value, so callers and tests can assert against one constant. A privateUnreadableToken = "[unreadable]"(PiiRedactor.cs:29) is the fallback for a throwing getter.Cache(PiiRedactor.cs:31): aConcurrentDictionary<Type, IReadOnlyList<RedactableProperty>>holding the reflected, per-type property metadata so a hot logging path does not re-run reflection on every call (this is what makes repeated redaction allocation-light).Redact(object?)(PiiRedactor.cs:42): the primary entry point.nullyields the shared empty map (PiiRedactor.cs:33-34,44-47); otherwise it walks the cached properties and builds an ordinal-comparerproperty-name → valuedictionary where each PII property is replaced byRedactedTokenand every other property passes through viaproperty.Read(value)(PiiRedactor.cs:49-56).RedactToString(object?)(PiiRedactor.cs:65): renders a single-lineTypeName { Prop = value, Pii = [REDACTED] }string for a log-message argument;nullyields the literal"null"(line 69), and non-PII scalars are formatted withCultureInfo.InvariantCulture(PiiRedactor.cs:84-86), keeping the rendering locale-stable (the same culture-invariance discipline asDomainHelper).HasPii(Type)(PiiRedactor.cs:98): throws on a nulltype, then returns whether the type declares any[Pii]property, i.e. whether redaction would mask anything (PiiRedactor.cs:98-110).GetProperties(Type)(PiiRedactor.cs:112): the cache filler.Cache.GetOrAddruns astaticlambda that reflects public, instance, readable, non-indexer properties and builds aRedactablePropertyfor each, recording whether it carries the marker viap.IsDefined(typeof(PiiAttribute), inherit: false)(PiiRedactor.cs:112-121). Theinherit: falsemirrorsPiiAttribute'sInherited = false.
- Why it's built this way: a
staticpure helper has no DI dependency, so it can be called from any layer, including a transport boundary, without wiring. Per-type caching keeps the logging path cheap; value-erasure (over truncation/hashing) is the conservative §30 choice; and routing personal data through one named gate makes the redaction policy auditable in one place (ADR-005). - Where it's used: unit-verified by
PiiRedactorTests(MMCA.Common/Tests/Core/MMCA.Common.Domain.Tests/Privacy/PiiRedactorTests.cs, G25) and exercised end to end (composed withIAnonymizable) byPiiErasureContractFitnessTests(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/PiiErasureContractFitnessTests.cs:19). No production logging call site routes entities through it, so it is a ready, tested control rather than an automatic one (see the caveat onPiiAttribute). - Caveats / not-in-source: redaction is shallow (one level), as the remarks state
(
PiiRedactor.cs:19): a non-PII property whose value is itself an object with nested[Pii]members is read and emitted as-is, not recursively masked. Only public instance properties are inspected (PiiRedactor.cs:115), so fields and non-public members are ignored. A property getter that throwsTargetInvocationExceptionyields[unreadable]instead of crashing the log call (PiiRedactor.cs:135-139).
IAnonymizable
MMCA.Common.Domain ·
MMCA.Common.Domain.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAnonymizable.cs:22· Level 3 · interface
- What it is: a single-method contract (
Result Anonymize()) for aggregates that store personal data and must support GDPR/CCPA right-to-erasure. - Depends on:
Result(viaMMCA.Common.Shared.Abstractions,IAnonymizable.cs:1). - Concept reinforced, reconciling soft-delete with erasure.
[Rubric §30, Compliance, Privacy & Data Governance](assesses a real erasure path, not just soft-delete). The doc comment (lines 5-21) explains the tension: soft-delete (IAuditableEntity.IsDeleted) hides a row from queries but retains its personal data, so it does not by itself satisfy an erasure request (IAnonymizable.cs:11-13).IAnonymizableprovides the erasure path: an application-layer erasure handler loads the aggregate, callsAnonymize(), and saves, overwriting PII fields with non-identifying placeholders in place rather than hard-deleting (lines 13-15). The row stays (FKs and audit trail intact); the person's data is gone. This is the second half of thePiiAttributestory (ADR-005):[Pii]marks what is PII;IAnonymizabledefines how it is erased.[Rubric §34, Architecture Governance & Documentation]: an architecture rule asserts that any Domain type with a[Pii]property implementsIAnonymizable(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/ArchitectureRules.Governance.cs:11-21), enforcing the contract executably rather than by review. - Walkthrough:
Anonymize()(line 30): aResultreturn type (notvoid) because anonymization can fail, and the doc comment describes the failure case as "a failure describing why anonymization could not be applied" (line 29). The summary mandates idempotency (lines 25-27): callingAnonymize()on an already-anonymized entity must be a no-op returning success, important under at-least-once erasure-event delivery. The remarks (lines 16-20) add the storage guidance: fields that must remain retrievable after erasure are persisted through the AES-256-GCMEncryptedStringConverter; fields that need not survive are overwritten with placeholders insideAnonymize(). - Why it's built this way: making erasure a one-method contract keeps the policy (which fields,
what placeholders) inside the aggregate that owns the data, while the trigger lives in an
application handler, and the
[Pii] ⇒ IAnonymizablefitness rule guarantees no PII-holding entity silently lacks an erasure path (ADR-005). - Where it's used: implemented by
Userin the ADC Identity module (which holds the[Pii]fieldsEmail/FirstName/LastName/AvatarUrl,MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:18,21,25,29,94); called by the application-layer erasure handler, enforced byPiiConventionTests(G25), and exercised together withPiiRedactorbyPiiErasureContractFitnessTests(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/PiiErasureContractFitnessTests.cs:19).
ValueObject
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/ValueObject.cs:8· Level 0 · record
- What it is: the abstract base for all value objects, declared as a single line:
public abstract record ValueObject;(ValueObject.cs:8). - Depends on: nothing first-party.
- Concept introduced, the Value Object.
[Rubric §4, Domain-Driven Design](assesses whether the model mirrors the business, aggregates, value objects, ubiquitous language, immutability). A value object models a concept with no identity: twoMoney(10, USD)instances are equal because their values are equal, not because they're the same row. By inheriting fromrecord, every value object gets compiler-generated structural equality (Equals/GetHashCodeover all declared properties) and immutability for free, the design rationale is stated in the doc comment (ValueObject.cs:3-7). This is the cheapest possible base: it adds a type (so code can say "this is a value object" and architecture tests can assert rules about them) without adding members. - Walkthrough: there are no members. The whole contract is "be a record, be abstract, be named
ValueObject". The work happens in derived types. - Why it's built this way: using C#'s
recordfor value-object semantics avoids hand-writing equality (a classic DDD chore and bug source). The base type exists so the family is nameable and enforceable, not for shared behavior. - Where it's used: base of
Address,Money,Email,Currency,DateRange,DateTimeRange,PhoneNumber, each adds a factory method returningResult<T>so an invalid value object can't be constructed. - Caveats / not-in-source: equality is purely structural; if a future value object holds a mutable collection, record equality would compare references, not contents. None of the current value objects do.
BaseEntity<TIdentifierType>
MMCA.Common.Domain ·
MMCA.Common.Domain.Entities·MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/BaseEntity.cs:14· Level 1 · class (abstract)
- What it is: the concrete base class for every domain entity: implements
IBaseEntity<TIdentifierType>with arequired init Id. - Depends on:
IBaseEntity<TIdentifierType>(Level 0). - Concept introduced, entity base class + EF Core materialisation pattern.
[Rubric §4, DDD](entities with clear identity). The doc comment (BaseEntity.cs:5-13) explains the two-path design: application code calls a static factory method (which setsIdexplicitly), while EF Core materialises existing rows through the parameterless constructor and then setsIdvia theinitaccessor. Becauseinitis only callable during object initialization,Idis immutable after construction regardless of which path is used.[Rubric §8, Data Architecture](deliberate key-generation strategy): whether the id is DB-generated or app-assigned is declared on the entity withIdValueGeneratedAttributeand read byEntityTypeExtensions.IsIdValueGenerated,BaseEntityitself is neutral on this. - Walkthrough: a single line:
public required TIdentifierType Id { get; init; }(BaseEntity.cs:17). Everything else is inherited or added in subclasses. - Why it's built this way:
requiredmeans a factory method cannot accidentally skip settingId; thewhere TIdentifierType : notnullconstraint (BaseEntity.cs:15) prevents nullable id types. The class isabstractso it cannot be instantiated directly.TIdentifierTypeis bound per-entity to a strongly-typed alias (OrderIdentifierType = int, named in the doc comment), see the identifier-alias convention in the primer. - Where it's used: base of
AuditableBaseEntity<TIdentifierType>→AuditableAggregateRootEntity<TIdentifierType>; every entity in both apps ultimately inherits from this.
EntityTypeExtensions
MMCA.Common.Domain ·
MMCA.Common.Domain.Extensions·MMCA.Common/Source/Core/MMCA.Common.Domain/Extensions/EntityTypeExtensions.cs:9· Level 1 · class (static, extension block)
- What it is: adds a single computed property
IsIdValueGeneratedtoSystem.Type, reading the presence ofIdValueGeneratedAttributevia reflection. - Depends on:
IdValueGeneratedAttribute(Level 0). - Concept, C#
extension(T)syntax (taught in the primer). Theextension(Type entityType)block atEntityTypeExtensions.cs:11means anyTypeinstance can call.IsIdValueGeneratedwithout inheriting from or being wrapped by anything:typeof(MyEntity).IsIdValueGenerated. The implementation (EntityTypeExtensions.cs:19):entityType.GetCustomAttribute<IdValueGeneratedAttribute>() is not null, a single reflection call, cached by factory methods in practice.[Rubric §8, Data Architecture](deliberate key-generation strategy declared at the entity level, not scattered in configuration). - Where it's used: entity factory methods call
typeof(TEntity).IsIdValueGeneratedto decide whether to assigndefaultforId(letting the DB generate it) or an explicit value; EF entity configurations also use it.
Address
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Address.cs:16· Level 3 · record (sealed)
- What it is: an immutable value object for a postal address.
AddressLine1is required; the remaining five fields (AddressLine2,City,State,ZipCode,Country) are optional to accommodate international formats. - Depends on:
AddressInvariants(mutual, see the cycle note below),Result,ValueObject. - Concept introduced, the value-object factory method returning
Result<T>.[Rubric §4, Domain-Driven Design](assesses value objects with enforced invariants) and[Rubric §15, Best Practices & Code Quality](assesses "unconstructable invalid state"). The key pattern: the constructor isprivate(Address.cs:43), preventing ad-hoc instantiation. The only public entry point is astatic Result<Address> Create(…)factory method (Address.cs:69-89). If the invariants pass,CreatereturnsResult.Success(new Address(…)); if they fail, it returns aResult.Failure. This makes an invalidAddressunconstructable, you cannot bypass validation withnew Address(…)from outside the class. The[JsonConstructor]on the private ctor (Address.cs:42) is the one exception:System.Text.Jsonis permitted to round-trip the object from serialized form (where the fields are already validated).[DataContract]/[DataMember(Order = …)]tags (Address.cs:15-40) pin the wire shape and serialization order, making the contract stable. - Documented cycle,
Address↔AddressInvariants. Both types are Level 3 in the same SCC (strongly connected component).Address.CreatecallsAddressInvariants.EnsureAddressLine1IsValid, whileAddressInvariants.EnsureAddressIsValidaccepts anAddress?parameter. This is deliberate: the invariant helper is the canonical place for constraints (max-length constants, error codes) while the value object owns construction. Because both reference the other, the leveling algorithm assigns them the same level rather than an impossible ordering.[Rubric §2, Design Patterns](mutual delegation is fine here; neither owns the other's core identity). - Walkthrough
Create(Address.cs:69): callsResult.Combine(AddressInvariants.EnsureAddressLine1IsValid(…))(Address.cs:77-78); on failure re-wraps the errors asResult.Failure<Address>. OnlyAddressLine1is validated at the value-object level; the other fields are length-checked by the FluentValidation rules in higher tiers.ToString()(Address.cs:93): joins the non-empty parts with,via LINQ, producing a human-readable address string.
- Why it's built this way: EF stores
Addressas an owned type viaOwnsOne(noted in the doc comment,Address.cs:13). Owned types have value semantics at the persistence level, they share the parent entity's row. The private constructor prevents accidentalnew Address()from EF materializers; the[JsonConstructor]provides a sanctioned JSON path. - Where it's used: applied to entity properties carrying a postal address;
Createis called from domain aggregate factories.AddressInvariantsconstants drive EF column max-lengths and theAddressLine1Rules<T>family of FluentValidation rules (consumed in turn byAddressValidator). Carried byRegisterRequest.
AddressInvariants
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/AddressInvariants.cs:9· Level 3 · class (static)
- What it is: a static helper that holds the address field max-length constants (shared by
EF configurations and FluentValidation validators) and two invariant-check methods that return
Result. - Depends on:
Address(mutual cycle, same SCC),Error,Result. - Concept introduced, the shared-constants invariant class.
[Rubric §16, Maintainability & Evolvability](a single place to change a constraint).AddressLine1MaxLength = 200(AddressInvariants.cs:12) is astatic readonly intthat EF entity configurations, FluentValidation rules, and this invariant all read from. Change it in one place and every layer picks it up. This is the pattern repeated for every value type: the invariant class is the single source of truth for both the constraint value and the error shape. Six max-length constants are declared (AddressInvariants.cs:12-27), one per address field. - Walkthrough
EnsureAddressLine1IsValid(string addressLine1, string source)(AddressInvariants.cs:50): checksstring.IsNullOrWhiteSpace; on failure returnsError.Invariant("Address.Line1.Empty", …)with the caller's method name assourcefor stack-free tracing.EnsureAddressIsValid(Address? address, string source)(AddressInvariants.cs:35): allowsnull(address is optional on many entities) and otherwise delegates toEnsureAddressLine1IsValid.
- Why it's built this way: separating the constants from the value object means EF
configurations can reference
AddressInvariants.AddressLine1MaxLengthwithout referencingAddressitself, keeping Infrastructure's dependency on Shared lightweight. - Where it's used: called inside
Address.Create; max-length constants referenced by EFEntityTypeConfigurationclasses (Infrastructure) and theAddressLine1Rules<T>family (Application).
AuditableBaseEntity<TIdentifierType>
MMCA.Common.Domain ·
MMCA.Common.Domain.Entities·MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:13· Level 3 · class (abstract)
- What it is: the second level of the entity hierarchy: extends
BaseEntity<TIdentifierType>with soft-delete, audit tracking, and optimistic concurrency. Every domain entity in both apps inherits (transitively) from this class. - Depends on:
BaseEntity<TIdentifierType>,Error,IAuditableEntity,Result. - Concept introduced, private setters on audit properties stamped via EF reflection.
[Rubric §8, Data Architecture](assesses audit fields stamped centrally, not per-handler) and[Rubric §3, Clean Architecture](the domain never sets its own audit fields, infrastructure does).CreatedOn,CreatedBy,LastModifiedOn,LastModifiedBy(AuditableBaseEntity.cs:25-31) all haveprivate set, the domain reads them but does not write them. The doc comment (AuditableBaseEntity.cs:8-10) explains the mechanism: EF Core'sSaveChangesAsyncoverride accesses these viaentry.Property(…).CurrentValuereflection, bypassing setter visibility. The#pragma warning disable S1144, CA1819(AuditableBaseEntity.cs:24) suppresses "private setter unused" and "return array from property" analyzer warnings that would otherwise fire, scoped and justified inline.[Rubric §10, Cross-Cutting Concerns](centralized stamping eliminates per-handler boilerplate). - Walkthrough
IsDeleted(AuditableBaseEntity.cs:20):virtual bool,private set, soft-delete flag.virtualso EF lazy-load proxies and test subclasses can override.RowVersion(AuditableBaseEntity.cs:39):byte[],private set, initialized to[]. EF maps this as a[Timestamp]/SQL Serverrowversioncolumn. Its presence inUPDATE/DELETEWHERE clauses is what makes optimistic concurrency automatic, if another writer changed the row between read and save, EF throwsDbUpdateConcurrencyException.Delete()(AuditableBaseEntity.cs:47):virtual Result, checksIsDeletedfirst (idempotency guard), returningError.AlreadyDeletedif so; otherwise sets it totrueand returnsResult.Success(). ReturningResultinstead ofvoidlets callers propagate the "already deleted" failure through the standard error flow.Undelete()(AuditableBaseEntity.cs:67):protected(only callable by derived classes that deliberately support reactivation, BR-135 in the ADC spec). MirrorsDelete()'s pattern: guard (Error.Invariant("Entity.NotDeleted", …)) → mutate → returnResult.
- Why it's built this way: three concerns (identity, audit, concurrency) are layered as separate
base classes so each can be tested and evolved independently.
AuditableAggregateRootEntity<TIdentifierType>(Level 4) adds domain-event collection on top. - Where it's used: every entity in Conference, Engagement, Identity, Notification inherits from
AuditableAggregateRootEntity<TIdentifierType>, which inherits from this. Used directly by child entities (non-aggregate-root entities that need audit but don't raise events).
Currency
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Currency.cs:14· Level 3 · record (sealed)
- What it is: an ISO 4217 currency value object using a closed set pattern: only
Currency.UsdandCurrency.Eurare public instances; the constructor is private.Noneis an internal sentinel forMoney.Zero(). - Depends on:
CurrencyJsonConverter(mutual cycle),Error,Result,ValueObject. - Documented cycle,
Currency↔CurrencyJsonConverter. Both are defined in the same file (Currency.cs:14and:65).Currencycarries[JsonConverter(typeof(CurrencyJsonConverter))](Currency.cs:13), whileCurrencyJsonConverter.ReadcallsCurrency.FromCode. Because of the mutual reference both are assigned Level 3. - Concept introduced, the closed-set (type-safe enum) pattern for value objects.
[Rubric §4, DDD](eliminates primitive obsession for currency codes). Rather than passing a rawstring "USD"around, all code usesCurrency.Usd, a statically typed singleton. New currencies are added by adding a field toAll(Currency.cs:54).FromCodevalidates at the boundary; once inside the domain you always hold a known-validCurrency. - Walkthrough
EmptyCurrency/InvalidCurrency(Currency.cs:17,20): pre-constructedErrorsingletons for the two failure paths, soFromCodedoesn't allocate a new error on each bad call.None(Currency.cs:23): aninternalempty-code sentinel used only byMoneyas an additive identity, never exposed to API consumers.FromCode(string code)(Currency.cs:41): empty-guard, then a case-insensitive linear scan overAll; on success returns the singleton (no allocation,Currency.Usdis the same object every time).All(Currency.cs:54):IReadOnlyCollection<Currency>, the extension point for adding currencies.
- Why it's built this way: pre-constructing the singleton instances avoids per-call allocation
and makes equality comparison trivial (reference equality). The closed set enforces that unknown
codes can never appear as
Currencyvalues inside the domain. - Where it's used:
Moneyholds aCurrency;CurrencyJsonConverterserializes it for API responses; the API-layerCurrencyJsonConverter(Level 4) registers the converter globally.
CurrencyJsonConverter
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Currency.cs:65· Level 3 · class (sealed)
- What it is: a
JsonConverter<Currency>that serializesCurrencyas its ISO code string and deserializes viaCurrency.FromCode. - Depends on:
Currency(mutual cycle, see above). - Concept reinforced, converter-per-value-object.
[Rubric §9, API & Contract Design](assesses consistent, stable serialization). Registering the converter on the record type itself (via the[JsonConverter]attribute onCurrency) means it applies automatically whereverCurrencyappears in a response, no per-controller configuration needed. - Walkthrough
Read(Currency.cs:68): reads the code string (returnsnullif the JSON token is null), callsCurrency.FromCode; on failure throwsJsonException(the correct boundary behavior, invalid JSON input is a deserialization error, not a domainResult.Failure).Write(Currency.cs:82):writer.WriteStringValue(value.Code), the wire shape is just the three-letter string.
- Where it's used: registered automatically via the
[JsonConverter]attribute onCurrency; theCurrencyJsonConverterwrapper inMMCA.Common.API(Level 4) registers the same converter withJsonOptionsglobally. - Caveats / not-in-source: the converter has no version sentinel; if the closed code set
changes, deserializing an old code from a stored document (e.g. a stale cache) will throw
JsonException.
DateRange
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/DateRange.cs:9· Level 3 · record (sealed)
- What it is: an immutable value object for a date-only range (
DateOnly Start,DateOnly End), inclusive on both ends, with the invariant thatEnd ≥ Start. - Depends on:
Error,Result,ValueObject. - Concept reinforced, lightweight single-invariant factory.
[Rubric §4, DDD](temporal ranges are domain concepts, not raw pairs ofDateTime). CompareDateRangetoAddress:Address.Createdelegates toAddressInvariants;DateRange.Create(DateRange.cs:30) enforces the singleend < startrule inline, no separate invariant class is needed when there is only one constraint. - Walkthrough
Create(DateOnly start, DateOnly end)(DateRange.cs:30): a one-liner conditional expression. Failure usesError.Validation(notError.Invariant) because the caller passes raw user-supplied dates, this is a validation problem, not a corrupted-state invariant.LengthInDays(DateRange.cs:38):End.DayNumber - Start.DayNumber,DayNumberavoidsTimeSpanarithmetic onDateOnly.Overlaps(DateRange other)(DateRange.cs:46): half-open interval logic (Start < other.End && End > other.Start), null-guarded, the standard range-overlap formula, documented in the method comment.Contains(DateOnly instant)(DateRange.cs:55): inclusive check.Deconstruct(DateRange.cs:61): lets callers usevar (start, end) = dateRange.
- Why it's built this way:
DateOnly(notDateTime) signals that the concept is purely date-level; using a value object instead of a bare pair prevents accidentally swappingstartandendat call sites. - Where it's used: event date ranges in the ADC Conference module (validated through
FluentValidation
EventDateRangeRules).
DateTimeRange
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/DateTimeRange.cs:10· Level 3 · record (sealed)
- What it is: the
DateTime-precision sibling ofDateRange; carriesDateTime StartandDateTime End. - Depends on:
Error,Result,ValueObject. - Concept: the same factory/invariant pattern as
DateRange. The difference isDuration(DateTimeRange.cs:39) returns aTimeSpaninstead of an integer day count. The structural shape is otherwise identical; this section cross-references rather than repeating. - Walkthrough:
Create(DateTime start, DateTime end)(DateTimeRange.cs:31) uses the same conditional-expression pattern (Error.Validationonend < start);Overlaps/Contains/Deconstruct(DateTimeRange.cs:46/55/61) mirrorDateRangeexactly. - Where it's used: session time slots and room scheduling windows in the Conference module.
EmailInvariants
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/EmailInvariants.cs:10· Level 3 · class (static, partial)
- What it is: invariant checks for email addresses: not-empty, max-length (256), and format via
a
[GeneratedRegex]method. - Depends on:
Error,Result. - Concept introduced,
[GeneratedRegex]for performance.[Rubric §12, Performance & Scalability](assesses avoiding allocations and per-call regex compilation). Thepartialclass/property pair (EmailInvariants.cs:10,54-55) lets the C# source generator bake a pre-compiledRegexinstance at compile time, with a hardmatchTimeoutMilliseconds: 1000to prevent catastrophic backtracking. Nonew Regex(…)at runtime. The pattern (^[^@\s]+@[^@\s]+\.[^@\s]+$) is "practical" rather than full RFC-5322, the doc comment (EmailInvariants.cs:17) is honest about this. - Walkthrough:
EnsureEmailIsValid(string email, string source)(EmailInvariants.cs:22): three sequential guards, empty, length, format, each returning a distinctError.Invariantwhose code encodes the failure sub-type ("Email.Empty","Email.TooLong","Email.InvalidFormat"). UsingError.Invariant(notError.Validation) signals these are domain-level data-integrity requirements, not user-input validations. - Why it's built this way: the same
MaxLength = 256constant (EmailInvariants.cs:13) is referenced by EFHasMaxLengthcalls, keeping the schema and the invariant in sync. - Where it's used: called from
Email.Create(Level 4) and the FluentValidation email rule helpers (Application).
PhoneNumberInvariants
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/PhoneNumberInvariants.cs:10· Level 3 · class (static, partial)
- What it is: invariant checks for phone numbers: not-empty, length range (7–20 chars), and
format via
[GeneratedRegex](^[\d\s\-\(\)\+]+$). - Depends on:
Error,Result. - Concept: the same
[GeneratedRegex]pattern asEmailInvariants; the guard sequence checks empty → length → format (PhoneNumberInvariants.cs:25onwards), and it validates against the trimmed string (PhoneNumberInvariants.cs:36).MinLength = 7andMaxLength = 20(PhoneNumberInvariants.cs:13,16) are the shared constants used by EF and FluentValidation. - Where it's used: called from
PhoneNumber.Create(Level 4).
AuditableAggregateRootEntity<TIdentifierType>
MMCA.Common.Domain ·
MMCA.Common.Domain.Entities·MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:13· Level 4 · class (abstract)
- What it is: the base class for aggregate roots: entities that own a consistency boundary,
collect domain events, and control child-collection mutation. Extends
AuditableBaseEntity<TIdentifierType>with a private_domainEventslist,SetItems<TChildEntity>, andGetChildOrNotFound. - Depends on:
AuditableBaseEntity<TIdentifierType>(Level 3);Error(Level 1);IAggregateRoot(Level 1);IAuditableEntity(Level 0);IDomainEvent(Level 0);Result(Level 2). - Concept introduced, the aggregate root pattern.
[Rubric §4, Domain-Driven Design](assesses aggregates as consistency boundaries that own child entities and raise domain events). An aggregate root is the sole entry point to a cluster of related entities: only the root exposes mutation methods; outsiders cannot reach children directly. In DDD terms the root protects the invariants of the whole cluster. This class embodies three aggregate responsibilities:- Domain event collection (
AddDomainEvent/ClearDomainEvents,AuditableAggregateRootEntity.cs:24,34), the aggregate records side-effects asIDomainEventobjects; they are dispatched by the infrastructure after a successfulSaveChangesAsync, not during the domain operation itself. This is the "domain events transact with the aggregate" design from ADR-003. - Child collection mutation (
SetItems<TChildEntity>,AuditableAggregateRootEntity.cs:44), replaces an entire child list atomically, calling theValidateSetItemshook (line 69, virtual, no-op by default) so subclasses can enforce business rules before the mutation occurs (e.g., preventing removal of fulfilled order lines). - Child lookup (
GetChildOrNotFound<TChild, TChildId>,AuditableAggregateRootEntity.cs:87), searches the in-memory collection for an active (non-soft-deleted) child by id, returningResult<TChild>rather than throwing.[Rubric §6, CQRS & Event-Driven](domain events raised at the aggregate level, dispatched after successful persistence).
- Domain event collection (
- Walkthrough
private readonly List<IDomainEvent> _domainEvents(AuditableAggregateRootEntity.cs:16): the in-memory accumulator.IReadOnlyCollection<IDomainEvent> DomainEvents(line 18) exposes it read-only so infrastructure can drain it but handlers cannot add events through the property.AddDomainEvent(IDomainEvent domainEvent)(AuditableAggregateRootEntity.cs:24): null-guards and appends. Called by entity factory and mutation methods inside the aggregate.ClearDomainEvents()(AuditableAggregateRootEntity.cs:34): called byApplicationDbContextafter dispatching, prevents re-dispatch if a second save occurs.SetItems<TChildEntity>(List<TChildEntity> collection, IEnumerable<TChildEntity> items)(AuditableAggregateRootEntity.cs:44): materializesitemsonce (avoiding double enumeration viaitems as IList<…> ?? [.. items]), callsValidateSetItems, thenClear()+AddRange(). The EF-tracked list reference (collection) is never replaced, only mutated, EF change tracking requires the original list reference to detect adds/removes.ValidateSetItems<TChildEntity>(AuditableAggregateRootEntity.cs:69): protected virtual, empty by default; override in aggregates that have rules about child-collection shape.GetChildOrNotFound<TChild, TChildId>(AuditableAggregateRootEntity.cs:87):FirstOrDefaultagainst the in-memory collection checkingId.Equals(childId) && !c.IsDeleted; returnsError.NotFound(with source and target set) when missing.
- Why it's built this way: ADR-003 (outbox + in-process dispatch) requires domain events to
"ride along" with the entity in memory and be captured during
SaveChangesAsync; the collection lives on the root because it is the consistency boundary.SetItems+ValidateSetItemsgives a single protected-hook mutation path, avoiding scattered collection-manipulation logic in handlers. - Where it's used: base class for every domain aggregate root in both ADC and Store (the ADC
Event,Session,Speaker,ConferenceCategory,User,UserSessionBookmark, and notification entities all extend it).
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Email.cs:13· Level 4 · record (sealed)
- What it is: a validated, normalized value object for email addresses. Construction always goes
through
Create, which rejects invalid formats and normalizes to lowercase; the private constructor is JSON-only. - Depends on:
ValueObject(Level 0);EmailInvariants(Level 3);Result<T>(Level 2). - Concept introduced, value-object factory + implicit conversion.
[Rubric §4, Domain-Driven Design](assesses rich value objects with invariant-protected construction). The pattern combines three ideas: (1) the private[JsonConstructor]-tagged constructor (Email.cs:19-20) prevents accidental construction outside JSON deserialization; (2) the staticCreatefactory validates and normalizes, returningResult<Email>rather than throwing; (3)public static implicit operator string(Email email)(Email.cs:42) lets the value object drop in where a plain string is expected without an explicit cast, a backward-compatibility bridge. The#pragma warning disable CA1308aroundToLowerInvariant(Email.cs:35-37) is justified inline ("Email addresses are conventionally lowercase per RFC 5321"), a scoped, explained suppression.[Rubric §15, Best Practices & Code Quality](justified suppressions, no blanket disables). - Walkthrough
Value(Email.cs:17):string, getter-only, the normalized address;[DataMember(Order = 1)]controls the wire-format order.Create(string value)(Email.cs:27): trims whitespace (null-safe viavalue?.Trim() ?? string.Empty), callsEmailInvariants.EnsureEmailIsValid, then converts to lowercase-invariant on success.implicit operator string(Email.cs:42): enablesstring s = email;without a cast.ToString()(Email.cs:45): delegates toValue. The EF doc comment (Email.cs:8-10) notesHasConversion(notOwnsOne), so the column stays a flatnvarchar, no nested owned-type table.
- Why it's built this way: normalizing at construction time lets the entire system compare and
store emails case-insensitively without per-use
.ToLower()calls. - Where it's used: domain entities that carry an email field;
RegisterRequestincludes a rawstring Emailthat gets converted to anEmailvalue object inside the domain factory.[Rubric §9, API & Contract Design](normalized shapes crossing layers).
Money
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Money.cs:18· Level 4 · record (sealed)
- What it is: a value object pairing a
decimal Amountwith aCurrency, with arithmetic operators, a safeAddmethod, andCurrency.Noneas a zero-accumulator sentinel. - Depends on:
ValueObject(Level 0);Currency(Level 3);Error(Level 1);Result<T>(Level 2). - Concept introduced, domain-level arithmetic on value objects.
[Rubric §4, DDD](rich value objects encapsulate behavior, not just data).Moneyis concept-rich:- Two static
Errorfields (NoCurrency,CurrencyMismatch,Money.cs:21,24) are class-level constants. This is the "error as a named constant" sub-pattern: callers can compareresult.Errors[0] == Money.CurrencyMismatchwithout string matching. operator +(Money.cs:68) delegates toAddand throwsInvalidOperationExceptionon mismatch, the unsafe operator path. The doc comment (Money.cs:61-64) explicitly warns: "PreferAddfor Result-based error handling." This is a deliberate usability trade-off: operator syntax for trusted arithmetic (order-line totalling within a known currency), the Result path for untrusted cross-currency input.Currency.Noneacts as the additive identity:AddUnchecked(Money.cs:115) preserves the other side's currency when one side isNone, enablingZero()as anEnumerable.Aggregateseed without knowing the target currency in advance.internal static Money CreateUnsafe(Money.cs:137) is exposed to test assemblies viaInternalsVisibleTo, a test-only back-door without a public loophole.[DataContract]/[DataMember]and[JsonConstructor](Money.cs:17,27,31,37) give the same controlled construction + serialization asEmail.
- Two static
- Walkthrough
Create(decimal amount, Currency currency)(Money.cs:51): null-guards the currency reference and rejectsCurrency.None(external callers must specify a real currency).operator +(Money.cs:68) andoperator *(Money.cs:80): conventional arithmetic (multiplication is byintquantity).Add(Money first, Money second)(Money.cs:91): Result-safe addition; returnsCurrencyMismatch(with source/target set) only when both sides have a real, differing currency, otherwise delegates toAddUnchecked.Zero()/Zero(Currency)(Money.cs:126,131): static factories for accumulator seeds.IsZero()(Money.cs:141) andIsNegative(Money.cs:35): convenience predicates.
- Why it's built this way: encapsulating arithmetic on the value object eliminates scattered
Amount + Amountcalls that ignore currency; the two-path design (operator +vsAdd) matches the two callers: trusted internal sums and API-boundary input validation. - Where it's used: configured as an EF
OwnsOnein entity configurations (Store modules); theZero()accumulator seed is used in order-total calculations; the UI formats it viaMoneyExtensions.
PhoneNumber
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/PhoneNumber.cs:13· Level 4 · record (sealed)
- What it is: a validated, trimmed value object for phone numbers; structurally parallel to
Email. - Depends on:
ValueObject(Level 0);PhoneNumberInvariants(Level 3);Result<T>(Level 2). - Concept: the same factory + implicit-conversion pattern as
Email.PhoneNumber.cs:13issealed record PhoneNumber : ValueObject. No lowercasing (phone numbers have no case), but trimming applies, the validation runs on the raw string and the storedValueisvalue.Trim()(PhoneNumber.cs:33). The EF doc comment (PhoneNumber.cs:8-10) notesHasConversionkeeps the column a plainnvarchar. - Walkthrough:
Value(getter-only,[DataMember(Order = 1)]),Create(string value)(PhoneNumber.cs:27) callingPhoneNumberInvariants.EnsurePhoneNumberIsValid, the implicitoperator string(PhoneNumber.cs:38), andToString(PhoneNumber.cs:41). - Where it's used: entities with phone fields (the Identity-module
Useraggregate in Store).
CommonInvariants
MMCA.Common.Domain ·
MMCA.Common.Domain.Invariants·MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:10· Level 3 · class (static)
- What it is: a shared library of four reusable domain invariant methods:
EnsureStringIsNotEmpty,EnsureStringMaxLength,EnsureIdIsNotDefault<TId>, andEnsureBytesAreNotEmpty. All returnResult, so an invariant check either passes (Result.Success()) or yields a typed invariant failure rather than throwing. - Depends on:
Result,Error(via theError.Invariant(...)factory). No external dependencies,string.IsNullOrWhiteSpaceandIEquatable<T>are BCL. - Concept introduced, centralized invariant helpers vs. per-entity duplication.
[Rubric §16, Maintainability], §16 assesses whether repeated rules have a single source of truth; here every "string must be non-empty"/"id must not be default" check resolves to one canonical implementation instead of being re-coded per entity.[Rubric §4, DDD], §4 assesses whether the domain layer expresses business invariants in ubiquitous language; these helpers standardize the shape of invariant errors (code/message/source/target) that module invariant classes speak. WithoutCommonInvariants, every module's invariant class would independently reimplementstring.IsNullOrWhiteSpacechecks with slightly different error codes and messages.CommonInvariantsprovides the canonical implementations; module-specific invariant classes delegate to these.[Rubric §1, SOLID], DRY is an SRP corollary: one place owns each kind of check, so a fix (e.g. tightening the max-length comparison) lands once. - Walkthrough (all members are
static, expression-bodied, and take the diagnostic quartetcode, message, source, targetso the helper itself stays domain-neutral):EnsureStringIsNotEmpty(string value, string code, string message, string source, string target)(line 21): fails withError.Invariant(...)whenstring.IsNullOrWhiteSpace(value), elseResult.Success()(lines 23–25).EnsureStringMaxLength(string? value, int maxLength, …)(line 38): accepts a nullable string and fails only whenvalue is not null && value.Length > maxLength(line 40), sonull/empty pass. Callers that also require non-empty compose this withEnsureStringIsNotEmpty.EnsureIdIsNotDefault<TId>(TId id, …)(line 54): constrainedwhere TId : struct, IEquatable<TId>and fails whenid.Equals(default)(line 57), detects the "id was never set" case (defaultint= 0, defaultGuid=Guid.Empty).EnsureBytesAreNotEmpty(byte[] value, …)(line 70): fails whenvalue is null || value.Length == 0(line 72), validates that a requiredbyte[](e.g. aRowVersion/concurrency token) has content.
- Why it's built this way: placing these in
MMCA.Common.Domain(notMMCA.Common.Shared) keeps them below the Application layer in the dependency stack while still being reachable by every module's domain invariant class; and returningResultrather than throwing is the codebase-wide flow-control convention (see the Result pattern in the primer). TheError.Invariantfactory tags each failure withErrorType.Invariant, which the API layer later maps to an HTTP status. - Where it's used: delegated to by the value-object invariant classes in this group
(
AddressInvariants,EmailInvariants,PhoneNumberInvariants, all Level 3) and by the module entity invariant classes:EventInvariants,SessionInvariants,SpeakerInvariants(Level 4, Conference),UserSessionBookmarkInvariants(Level 4, Engagement), andUserInvariants(Level 4, Identity). Exercised byCommonInvariantsTests.
⬅ Result & Error Handling • Index • Querying: Specifications, Filtering & the Entity Query Service ➡