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>toAuditableBaseEntity<TIdentifierType>toAuditableAggregateRootEntity<TIdentifierType>) plus the contracts that describe each rung (IBaseEntity<TIdentifierType>,IAuditableEntity,IRowVersioned,IAggregateRoot). Each rung adds exactly one capability: identity (and identity equality), then audit/soft-delete/concurrency, then domain-event collection and the aggregate helpers. Three opt-in markers sit beside the chain rather than in it:ITenantEntity(this row belongs to one tenant),IAuditedEntity(record this entity's change history), andIReactivatable(this entity's soft delete may be reversed). - 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 smart-enumeration trio (Enumeration<TEnumeration>with itsEnumerationJsonConverterFactoryand the factory's nestedEnumerationConverter<TEnumeration>) is a deliberate cousin of this family rather than a member of it, for a reason spelled out below. - The governance markers and helpers, the attributes and small utilities that drive
metadata-based behavior across the stack:
PiiAttribute(erasure, log masking, and audit-trail redaction) with its redaction halfPiiRedactorand that helper's cachedRedactablePropertydescriptor,IdValueGeneratedAttribute+EntityTypeExtensions(database-generated IDs),IAnonymizable(GDPR/CCPA erasure), theDomainEntityStateenum (state-change classification for domain events),EventNameAttributeandIHasOrderingKey(the two things an event declares in the domain that the outbox reads in infrastructure), andDomainHelper(culture-invariant identifier parsing).
All of these live in the two innermost layers, MMCA.Common.Shared (value objects, invariants,
enumerations, 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:34) carries a single
required init identifier of the per-entity alias type, constrained where TIdentifierType : notnull
(BaseEntity.cs:35-37), 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, and ADR-048
(revisited with named re-open triggers in
ADR-085) for why they
are global using aliases over primitives rather than strongly-typed ID structs. 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.
The base rung also owns identity equality, which is the DDD definition of what makes two entity
references the same thing. Equals(object?) is true for the same reference, or for another instance of
the same concrete type whose Id is assigned and equal (BaseEntity.cs:71-77), with ==/!=
delegating to it (BaseEntity.cs:51-61) and GetHashCode combining the concrete type with the id
(BaseEntity.cs:93). Two transient instances (both ids still at the identifier type's default, the
state of an [IdValueGenerated] entity before the database stamps its key) are equal only when they are
the same reference, because a default id means "not identified yet" and not "identified as zero"
(HasAssignedId at BaseEntity.cs:102-103). The consequence worth memorizing is the hashing caveat
written on the type itself: the hash changes when the store assigns the key, so a database-generated
entity must not be bucketed in a HashSet or used as a dictionary key before the save that identifies
it (BaseEntity.cs:84-91). Code that has to track pre-save instances keys them by reference instead.
Note also what the type deliberately omits: IEquatable<T> is not implemented, because an unsealed
IEquatable<T> breaks the equality contract for subclasses (S4035), the same trade-off documented on
Enumeration and RoleValue (BaseEntity.cs:26-31).
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, with Delete() at AuditableBaseEntity.cs:67 refusing to double-delete
and returning Result), audit fields (CreatedOn/By,
LastModifiedOn/By at AuditableBaseEntity.cs:25-31) and the deletion stamp pair DeletedOn /
DeletedBy (AuditableBaseEntity.cs:39 and :45) that answers "when was this deleted, and by whom"
without an audit-trail lookup, all with private setters, plus the RowVersion
optimistic-concurrency token (AuditableBaseEntity.cs:53). The domain never writes any of them: 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:52-60),
freezes CreatedOn/By as unmodified on updates (AuditSaveChangesInterceptor.cs:67-68), and writes or
clears DeletedOn/By only when the soft-delete flag actually transitions
(AuditSaveChangesInterceptor.cs:98-105). Undelete() is deliberately protected
(AuditableBaseEntity.cs:89): reversing a soft delete is a per-entity business decision, not a
capability the base hands out. 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 the type's
own doc comment at IRowVersioned.cs:3-10 and in
ADR-035). This rung is where
[Rubric §8, Data Architecture] (soft-delete, audit, concurrency) meets [Rubric §12, Performance & Scalability]:
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, a
read-only DomainEvents view, ClearDomainEvents, and the surgical RemoveDomainEvents
(AuditableAggregateRootEntity.cs:16-50), and it adds four protected helpers that let a root police
its own consistency boundary without each aggregate hand-rolling the same loops:
SetItems<TChildEntity>replaces a child collection through an overridableValidateSetItemshook, so a root can veto (say) removing a shipped order line (AuditableAggregateRootEntity.cs:60-90).GetChildOrNotFound<TChild, TChildId>finds an active, non-soft-deleted child by id or returns anError.NotFoundfailure (AuditableAggregateRootEntity.cs:103-120).RemoveChildOrNotFound<TChild, TChildId>is that lookup followed by the child's ownDelete(), short-circuiting on either failure and handing the deleted child back rather than consuming it, because which domain event a removal raises is aggregate vocabulary the framework must not invent (AuditableAggregateRootEntity.cs:156-178).RestoreChild<TChild, TChildId>brings a soft-deleted child back (BR-135). It takes the child as an instance rather than an id, because a soft-deleted row is excluded by the global query filter and is not reachable through the loaded collection: the caller resolves it with anignoreQueryFiltersread and hands it in. The helper enforces only the "must actually be soft-deleted" rule, callsReactivate(), and re-adds the child only when the collection does not already carry it (AuditableAggregateRootEntity.cs:212-249, the duplicate guard at:243). ItsTChildis constrained toIReactivatable, which is exactly how the type system expresses "resurrection is opt-in".DeleteChildren<TChild, TChildId>cascades a soft delete across a child collection, skipping already-deleted children so re-deleting a parent stays idempotent, and combining the failures into one result (AuditableAggregateRootEntity.cs:273-292).
RemoveDomainEvents is worth pausing on: it takes out exactly the events the persistence layer
captured, matched by reference equality (AuditableAggregateRootEntity.cs:43), because two
structurally equal events raised separately are still two distinct occurrences. 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.
Three opt-in markers beside the chain
Not every cross-cutting capability belongs on the inheritance chain, because not every entity should
pay for it. ITenantEntity
(MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/ITenantEntity.cs:33) declares a single
read-only string TenantId (ITenantEntity.cs:39), and marking an entity with it buys two behaviors
at once. On reads, a named Tenant global query filter is applied alongside the existing
SoftDelete filter (ApplyTenantFilters at
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:428
and entity.HasQueryFilter(TenantFilterName, filter) at ApplicationDbContext.cs:486, with the filter
name constant at ApplicationDbContext.cs:391 and the soft-delete filter at ApplicationDbContext.cs:379);
named filters compose with AND, so a tenant sees neither another tenant's rows nor soft-deleted ones. On
writes, TenantSaveChangesInterceptor
stamps the value on insert and refuses a cross-tenant save, which is why the property is read-only on
the domain type: the value is not a caller's to choose (ITenantEntity.cs:16-20). The identifier is a
64-character string on purpose, because it arrives from a claim, a header, or configuration, all of
which are strings (ITenantEntity.cs:22-25). Adopting tenancy is three things together: marking
entities, calling AddMultiTenancy(configuration)
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:525), and setting
Tenancy:Enabled (ITenantEntity.cs:26-31); a host that never resolves a tenant behaves exactly as it
did before (ADR-073).
IAuditedEntity
(MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAuditedEntity.cs:34) is a pure marker, no
members at all, and it opts an entity into a field-level change history: every insert, update and
delete recorded as immutable rows by
AuditTrailSaveChangesInterceptor
into AuditTrailEntry rows. The doc comment is the
best explanation of why it is a marker and not a default: a trail is one row per changed property per
save, so trailing everything multiplies write volume without anyone asking for it
(IAuditedEntity.cs:9-14). It composes with IAuditableEntity but does not
require it, because the two answer different questions: IAuditableEntity stamps the CURRENT state,
IAuditedEntity records the SEQUENCE that produced it, and when both are present the trail rows see the
freshly stamped values because the trail is captured after the stamping interceptor has run
(IAuditedEntity.cs:16-21). Like tenancy, recording is host-gated behind AddAuditTrail(configuration)
(DependencyInjection.cs:476) plus AuditTrail:Enabled, so marking an entity in a host that never
opted in is inert (IAuditedEntity.cs:23-26).
IReactivatable
(MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IReactivatable.cs:19) is the third marker and
the smallest: one Result Reactivate() member (IReactivatable.cs:25). It exists because
AuditableBaseEntity.Undelete() is non-public, so an entity that wants to support un-deletion publishes
that decision by implementing this interface, typically as
public Result Reactivate() => Undelete(); (IReactivatable.cs:5-17). The payoff is compile-time: the
RestoreChild helper constrains its child to IReactivatable, so a child that never opted in simply
cannot be restored through the framework. All three markers are [Rubric §8, Data Architecture] and
[Rubric §30, Compliance/Privacy/Data Governance] concerns
(ADR-075 for the trail), and all three are
deliberately zero-cost until switched on, which is the [Rubric §31, Cost/FinOps] half of the same
decision.
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 that has pending events via
context.ChangeTracker.Entries<IAggregateRoot>(), snapshotting each aggregate's event list at capture
time
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:207,
:220-224), serializes them into OutboxMessage rows in
the same transaction as the data (DomainEventSaveChangesInterceptor.cs:236-246), then after a
successful save dispatches the local IDomainEvents in
process, marks their outbox rows processed, and removes exactly the captured events from each aggregate
(DomainEventSaveChangesInterceptor.cs:361-364). That last step is why IAggregateRoot grew
RemoveDomainEvents: clearing wholesale would also discard anything a handler raised on the same
aggregate during in-process dispatch, and those events would never dispatch and never reach the outbox
(IAggregateRoot.cs:25-31). Integration events
(IIntegrationEvent) deliberately get rows but no
in-process dispatch; the OutboxProcessor publishes them
(DomainEventSaveChangesInterceptor.cs:249-257). Inside a transactional command the whole flush is
deferred until after commit through a DeferredDispatch record
(DomainEventSaveChangesInterceptor.cs:313-314), 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, and it is the discriminator the CRUD lifecycle
taxonomy is built on (ADR-083).
The aggregate base is the producer end of the at-least-once outbox pipeline
(ADR-003); the consumer end lives in
Group 04.
Two domain-side declarations shape what that pipeline does with an event, and both live here rather
than in infrastructure because they are properties of the contract, not of the transport.
EventNameAttribute
(MMCA.Common/Source/Core/MMCA.Common.Domain/Attributes/EventNameAttribute.cs:32) pins a stable
serialization identity such as "Sales.OrderPlaced.v1" (EventNameAttribute.cs:35, rejecting an empty
or whitespace name at construction at :45). Without it an outbox row records the event's CLR
assembly-qualified name, so renaming the class or moving it to another namespace or assembly orphans
every row already written under the old name; with it, the row records a name no refactoring touches
(EventNameAttribute.cs:8-11). The catch is timing: it changes only what NEW rows store, so applying it
while the outbox holds pending rows is a two-step move, drain first and then rename
(EventNameAttribute.cs:15-19). EventNameResolver
reads the attribute in both directions
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/EventNameResolver.cs:38 and
:80), and a versioned name leaves room for the upcasting path
(ADR-090).
IHasOrderingKey
(MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IHasOrderingKey.cs:24) is the opt-in ordering
contract: an event returns a key naming the entity whose stream must stay sequential, typically the
aggregate id, or null to opt that individual instance out (IHasOrderingKey.cs:26-30). The outbox
copies the value onto the row it writes
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:116) and the
processor refuses to claim a row while an earlier unprocessed, non-dead-lettered row carries the same
key in the same data source
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:552-553,
with an in-batch guard at :516), so ordering holds across batches and across scaled-out replicas
rather than only within one batch. Read the doc comment before adopting it, because the trade-off is
explicit: this is head-of-line blocking by design, so keys must be as NARROW as the requirement really
is (one key per aggregate serializes that aggregate, a constant key serializes the whole outbox), and a
dead-lettered row stops blocking so one poison event cannot freeze its key forever
(IHasOrderingKey.cs:15-22). That pairing of a domain-declared intent with an indexed infrastructure
predicate (ApplicationDbContext.cs:559-561) is a compact [Rubric §12, Performance & Scalability] and
[Rubric §29, Resilience & Business Continuity] example.
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 (ADR-068).
The validation logic itself is factored out into static invariants classes,
AddressInvariants
(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/AddressInvariants.cs:9),
EmailInvariants
(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/EmailInvariants.cs:11), and
PhoneNumberInvariants
(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/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:13) is the reusable lower
layer that module-specific invariants delegate to, and it has grown into a 24-method toolbox: the
string/id basics EnsureStringIsNotEmpty (:30), EnsureStringMaxLength (:47),
EnsureIdIsNotDefault<TId> (:63) and EnsureBytesAreNotEmpty (:79); the numeric checks
EnsureIntIsPositive (:94), EnsureMoneyIsNotNegative (:110), EnsureNullableIntIsPositive
(:410) and EnsureIntIsNotNegative (:426); the collection checks EnsureCollectionIsNotEmpty<T>
(:126), EnsureCollectionIsEmpty<T> (:327), EnsureCountIsWithin (:310) and
EnsureValuesAreUnique<T> (:348); the string-shape checks EnsureStringLengthIsWithin (:219),
EnsureOptionalStringMaxLength (:242) and EnsureUrlIsWellFormed (:293); the typed checks
EnsureEnumIsDefined<TEnum> (:177), EnsureEndIsNotBeforeStart<T> (:197) and
EnsureTimeZoneIsValid (:266); the boolean assertions EnsureFlagIsTrue (:378) and
EnsureFlagIsFalse (:394); plus the two preference checks EnsurePreferredCultureIsValid (:142)
and EnsurePreferredThemeIsValid (:158, matching the light/dark constants at
CommonInvariants.cs:16-19 case-insensitively). 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-41). 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/Contact/Address.cs:16) andMoney(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Financial/Money.cs:21) are stored by EF asOwnsOnenested columns; both carry[DataContract]with ordered[DataMember(Order = n)]properties to pin the serialization shape (Address.cs:19-40,Money.cs:20-35).Addressrequires onlyAddressLine1and leaves the other five fields optional for international formats (Address.cs:69-78).Moneyis the richest: it pairs adecimal Amountwith aCurrency, defines+and*operators and aResult-returningAdd(Money.cs:84,:96,:107), and treatsCurrency.Noneas an additive identity soMoney.Zero()works as an accumulator seed regardless of the eventual currency (Money.cs:131-142). Note the asymmetry worth remembering: the+operator throwsInvalidOperationExceptionon a currency mismatch (Money.cs:89) whileAddreturns aCurrencyMismatchfailure (Money.cs:112), so preferAddin domain code.Moneyalso asks to be mapped through the shippedOwnsMoneyhelper rather than a hand-rolledOwnsOneblock, so the currency round-trip fallback is not re-typed per entity (Money.cs:14-19). - Closed enumeration:
Currency(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Financial/Currency.cs:14) is a record with a private constructor (Currency.cs:31) 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:73) serializes it as its bare ISO-4217 code on the wire, throwing aJsonExceptionon a non-string token or an unknown code when reading (Currency.cs:78-85). - Converted scalars:
Email(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/Email.cs:16) andPhoneNumber(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/PhoneNumber.cs:16) are stored via EFHasConversion(notOwnsOne) through the shippedEmailValueConverter/PhoneNumberValueConverterpairs (and their nullable siblings), so the column stays a flatnvarchar(Email.cs:7-13,PhoneNumber.cs:7-14). Both normalize on creation (Emailtrims then lowercases withToLowerInvariant,Email.cs:30-39;PhoneNumbertrims,PhoneNumber.cs:36) and overrideToStringto return the underlying value (Email.cs:44,PhoneNumber.cs:40). - Interval pairs:
DateRange(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Time/DateRange.cs:9,DateOnlybased) andDateTimeRange(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Time/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,DateTimeRange.cs:31-36). Read the boundary rules carefully:Containsis inclusive on both ends (DateRange.cs:55-56) whileOverlapscompares half-open (DateRange.cs:46-50).
Smart enumerations, a closed set that can carry behavior
Enumeration<TEnumeration>
(MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:71) is the answer to a
recurring shape a CLR enum handles badly: a closed set of named members that need behavior hanging off
them (policies, rates, display rules) instead of a switch statement somewhere else
(Enumeration.cs:13-18). Members are declared as public static readonly fields on the derived type and
discovered by reflection over that type's own declared fields on first use, then frozen into a
ReadOnlyCollection plus two FrozenDictionary lookups keyed by value and by name
(Enumeration.cs:74-82, Enumeration.cs:165); All, FromValue, and FromName read from those
(Enumeration.cs:105, :115, :136). The two resolvers return
Result<TEnumeration> with Enumeration.UnknownValue /
Enumeration.UnknownName codes rather than throwing, which is the same contract every value-object
factory in this group honors. Plain CLR enums stay the default, and this base is the documented opt-in
for the one case they cannot cover
(ADR-104).
The interesting part is what it deliberately does not do. It does not derive from
ValueObject, because the ValueObjectsAreImmutableSealedInShared fitness rule
(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Domain/ArchitectureRules.Immutability.cs:56)
forces every ValueObject derivative to be a sealed record in the Shared layer, which would forbid the
static-member idiom this type exists for (Enumeration.cs:26-29). It also does not implement
IEquatable<T>, for the same S4035 reason BaseEntity<TIdentifierType>
does not; equality is a type-guarded Equals(object?) override instead (Enumeration.cs:37-42,
Enumeration.cs:152). On the wire, EnumerationJsonConverterFactory
(Enumeration.cs:195) walks the base chain to confirm a type is the self-referencing closed type and no
further derivative (Enumeration.cs:198-199, :213-222), then builds the private nested
EnumerationConverter<TEnumeration> (Enumeration.cs:224), which
writes the member's Name and reads it back through FromName, throwing JsonException on a non-string
token or an unknown name (Enumeration.cs:227-242) exactly the way CurrencyJsonConverter does, so the
non-MVC paths (cache, outbox, integration events, typed HttpClient calls) fail the same way MVC model
binding does. Note the registration gotcha the doc comment calls out: System.Text.Json reads
[JsonConverter] off the type being converted without walking base types, so a concrete enumeration
either repeats the attribute or the host registers the factory once in JsonSerializerOptions.Converters
(Enumeration.cs:44-49). This is a [Rubric §9, API & Contract Design] and [Rubric §15, Best Practices &
Code Quality] decision: one serialization shape, chosen once, with the trade-off written down where the
next reader will find it.
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).
Three mechanisms rely on the marker today. 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/Governance/PiiConventionTests.cs:13 over the rule
body at
MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Governance/ArchitectureRules.Governance.cs:11-17; the
scan is structurally vacuous inside the framework itself because no data-subject type lives in
MMCA.Common.Domain). 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-53), 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,
the descriptor itself at PiiRedactor.cs:123), 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).
Third, the audit trail consumes both halves:
AuditTrailSaveChangesInterceptor
calls PiiRedactor.HasPii per entity type and writes PiiRedactor.RedactedToken on both sides of a
change for a [Pii] property
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:286,
:309-310), so the trail never becomes a second, unerasable copy of a data subject's personal data.
Scope note worth keeping: redaction of logs is still an opt-in helper you call, not an automatic
logging pipeline; 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:13-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. Fields that must remain retrievable are persisted through the AES-256-GCM
EncryptedStringConverter instead (IAnonymizable.cs:16-20). 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-19), to decide
whether to assign an explicit id or leave it default for the database to fill. This is the same
attribute the equality caveat above turns on, which is why the two types are worth reading together.
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 offers two extension members on string? (DomainHelper.cs:13):
Parse<TIdentifier>() (DomainHelper.cs:30), which coerces by design so an unparsable route value
degrades to a not-found lookup, and TryParse<TIdentifier>(out TIdentifier) (DomainHelper.cs:58),
which reports success instead, for the bool and enum callers where malformed input is otherwise
indistinguishable from a legitimate default. Both handle string, Guid, int, long, ulong,
bool, and enums, and both throw FormatException for an unsupported identifier type
(DomainHelper.cs:106, DomainHelper.cs:166). The CultureInfo.InvariantCulture parsing throughout 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 and deletion fields these types declare, applies the global
soft-delete query filter keyed off IAuditableEntity and the tenant filter keyed
off ITenantEntity, and writes the change history for
IAuditedEntity; the events/outbox group (Group 04)
drains the domain events aggregates raise and reads the EventNameAttribute and
IHasOrderingKey declarations they carry; 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:5-8, taxonomy recorded in ADR-083). - Where it's used: it is the first positional member of
EntityChangedEvent<TIdentifierType>(EntityChangedEvent.cs:24-26), so every derived per-entity change event carries it; the base's own usage note spells out the convention (EntityChangedEvent.cs:10-13): raiseAddedfrom factories,Updatedfrom mutation methods,DeletedfromDelete(). Aggregates follow it literally (for exampleCategoryatMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:72,95,111for the root andCategory.cs:144,176,194for its child items), and handlers short-circuit on it (for exampleMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:29andMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/DomainEventHandlers/SessionQuestionSubmittedPointsHandler.cs:60).
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 two generic extension members to
string?,Parse<TIdentifier>()andTryParse<TIdentifier>(out TIdentifier), 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:30): special-casesstring(returns the value or empty, lines 34-35), short-circuits null/whitespace todefault(lines 37-38), then delegates toParseNonEmpty(line 40).TryParse<TIdentifier>(out TIdentifier)(DomainHelper.cs:58) is the reporting sibling: thestringcase returnsid is not null(lines 62-66), null/whitespace returnsfalsewith the type default (lines 68-72), and anything else delegates toTryParseNonEmpty(line 74). The two exist becauseParsecoerces by design and the doc comment says so (lines 21-29):"maybe"and"false"both yieldfalse, an unrecognized enum name yields the enum default,"abc"yields0. That is fine for a route id (an unparsable value degrades into a not-found lookup) and wrong when malformed input must be told apart from a legitimate default, which is exactly when a caller reaches forTryParse.ParseNonEmpty<TIdentifier>(line 80) andParseOtherTypes<TIdentifier>(line 95): a chain oftypeof(TIdentifier) == typeof(Guid|int|long|ulong|bool)plustype.IsEnumchecks (lines 83-104) using culture-invariantTryParse; an unsupported type throwsFormatException(line 106). Each failedTryParsefalls back to the type's zero/empty value rather than throwing.TryParseNonEmpty(line 111) andTryParseOtherTypes(line 138) mirror that chain over the same six shapes and return the parse verdict instead of swallowing it, with the sameFormatExceptionon an unsupported type (line 166). Splitting each path into two private methods keeps every method within the analyzers' cyclomatic-complexity budget.- Note the two scoped
#pragma warning disable IDE0051blocks aroundParseNonEmpty(lines 79-81) andTryParseNonEmpty(lines 110-112), each with a comment (lines 78 and 109) explaining it is a false positive: the analyzer cannot see that the method is called from inside theextensionblock. A justified, narrow suppression.
- Why it's built this way: a page or endpoint receives ids as
stringroute values; this converts them to the entity's id alias type without the caller coupling to a specific id type, generic overTIdentifier(the alias policy itself is ADR-048). Culture-invariant parsing avoids locale-dependent bugs and is one of the few places §27 (i18n) bites, see primer §6. - Where it's used: the call sites in shipped code are Blazor detail pages turning their
[Parameter] stringroute value into the module's identifier alias, for exampleMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerDetail.razor.cs:106,.../Pages/Session/SessionDetail.razor.cs:104,.../Pages/Event/EventDetail.razor.cs:86, andMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Pages/Feedback/EventFeedback.razor.cs:254; Store's detail pages import the same namespace (for exampleMMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/Pages/Product/ProductDetail.razor.cs:4). Unit-covered byDomainHelperTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Extensions/DomainHelperTests.cs, G25). - 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).
EventNameAttribute
MMCA.Common.Domain ·
MMCA.Common.Domain.Attributes·MMCA.Common/Source/Core/MMCA.Common.Domain/Attributes/EventNameAttribute.cs:32· Level 0 · class (sealed attribute)
- What it is: declares a stable serialization identity for a domain or integration event, used wherever the event is stored rather than passed in memory: the outbox row that carries it to the bus and the inbox row that dedupes it on the consumer side.
- Depends on:
System.Attribute(BCL) only. It is read byEventNameResolverin Infrastructure. - Concept introduced, contract identity versus CLR identity.
[Rubric §9, API & Contract Design](assesses whether a published contract has a name independent of its implementation) and[Rubric §15, Best Practices & Code Quality](assesses whether an ordinary refactoring can break persisted data). The problem the attribute solves is stated in its own doc comment (EventNameAttribute.cs:3-13): without it, an outbox row records the event's CLR assembly-qualified name, so renaming the class, moving it to another namespace, or moving it to another assembly orphans every row already written under the old name (the processor cannot resolve the type and eventually dead-letters it). With it, the row records a name that no refactoring changes.[Rubric §7, Microservices Readiness]also applies: once an event crosses a service boundary, its name is part of the wire contract, and a contract name such as"Sales.OrderPlaced.v1"is exactly what a consumer in another deployable binds to. - Walkthrough
[AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = false)](EventNameAttribute.cs:31).Inherited = falseis load-bearing: a derived event does not silently borrow its base's identity, andEventNameResolverreflects withinherit: falseto match (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/EventNameResolver.cs:38).- A primary constructor takes the name and
Nameexposes it (EventNameAttribute.cs:32,35). Validated(string)(EventNameAttribute.cs:43-47) runsArgumentException.ThrowIfNullOrWhiteSpaceat construction (line 45): a blank identity would be stored on every row of that event and could never be resolved back to a type, so the failure is moved to type-load time rather than discovered in a dead-letter queue.- The remarks (
EventNameAttribute.cs:14-24) carry two operational rules. First, the attribute only changes what new rows store, so rows already persisted under a CLR name keep resolving by that name and stop resolving if it goes away: adopting it while an outbox holds pending rows is a two-step move (drain, then rename). Second, the name must be unique across the events a host can resolve, because reverse lookup matches on it; a versioned contract name leaves room for the upcasting path (ADR-090) when the payload itself changes shape. The doc includes a worked example (lines 25-28).
- Why it's built this way: the attribute lives in Domain with no infrastructure reference, so
the contract identity is declared next to the event it names, while the two places that consume it
(outbox write, inbox dedupe key) stay in Infrastructure. The decision and its trade-off are recorded
in ADR-003 (
003-outbox-dual-dispatch.md:202-212). - Where it's used: the single reader is
EventNameResolver(EventNameResolver.cs:19), which caches the declared name per type including thenull"no attribute" answer (EventNameResolver.cs:26,35-38) and exposes three views:GetStorageName(declared name, else assembly-qualified name,:47-51) written intoOutboxMessage.EventType(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:107);GetInboxName(declared name, else short type name,:59-60) used as the inbox dedup key byIntegrationEventConsumer(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/IntegrationEventConsumer.cs:43) andUpcastingIntegrationEventConsumer(.../UpcastingIntegrationEventConsumer.cs:62); andFindTypeByDeclaredName(:75-81), the reverse lookup that scans loaded assemblies when a stored name is not a CLR type name, reached fromOutboxMessage's cached type resolution (OutboxMessage.cs:153) and degrading gracefully past an unloadable assembly (:90-100). Applied today to the framework's ownOutputCacheEvictionRequested(MMCA.Common/Source/Core/MMCA.Common.Domain/IntegrationEvents/OutputCacheEvictionRequested.cs:28) and to every cross-service integration event in the apps: ADC'sUserRegistered(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/IntegrationEvents/UserRegistered.cs:24),UserDeleted(.../UserDeleted.cs:25),SpeakerLinkedToUser(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerLinkedToUser.cs:21),SpeakerUnlinkedFromUser(.../SpeakerUnlinkedFromUser.cs:18),SessionFeedbackSubmitted(.../Sessions/IntegrationEvents/SessionFeedbackSubmitted.cs:20),EventFeedbackSubmitted(.../Events/IntegrationEvents/EventFeedbackSubmitted.cs:19),AttendeeCheckedIn(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/IntegrationEvents/AttendeeCheckedIn.cs:23), Store'sProductVariantChanged(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/Products/IntegrationEvents/ProductVariantChanged.cs:27), and Helpdesk'sTicketOpenedIntegrationEvent(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Shared/Tickets/IntegrationEvents/TicketOpenedIntegrationEvent.cs:16). Covered byOutboxMessageTests(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Outbox/OutboxMessageTests.cs:25) andIntegrationEventConsumerTests(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Messaging/Consumers/IntegrationEventConsumerTests.cs:17). - Caveats / not-in-source: uniqueness of the declared name is a documented requirement
(
EventNameAttribute.cs:20-22), not an enforced one. No fitness test or startup check asserts that two event types in the same host do not declare the same name;FindTypeByDeclaredNamesimply takes the first match its assembly scan produces (EventNameResolver.cs:79).
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,DeletedOn/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,DeletedOn?line 26,DeletedBy?line 29) 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: seven getter-only properties.
CreatedByisUserIdentifierType; the other three identifier members areUserIdentifierType?, null until the corresponding transition happens, and they pair with their nullable timestamps so "when was this deleted, and by whom" is answerable from the row itself. 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 centralizing discipline §8/§15 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-45); recognized by the auditSaveChangesinterceptor and the soft-delete query filter (G07), and read byApplicationDbContextwhen it decides whether a tenant index needs anIsDeletedsecond column (.../Persistence/DbContexts/ApplicationDbContext.cs:459-461). It answers "who touched this row last"; the sequence that produced the row is the separate, opt-inIAuditedEntitymarker. ItsIsDeletedflag is the counterpart thatIAnonymizabledeliberately does not satisfy on its own (see ADR-005, and the explicit statement of that gap inIAnonymizable.cs:11-12).
IAuditedEntity
MMCA.Common.Domain ·
MMCA.Common.Domain.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IAuditedEntity.cs:34· Level 0 · interface (marker)
- What it is: an opt-in marker. An entity that carries it has every insert, update and delete recorded as an immutable field-level change history (who changed which field, from what to what, and when).
- Depends on: nothing first-party. The declaration is a bodyless interface,
public interface IAuditedEntity;(IAuditedEntity.cs:34); all of the behavior lives in infrastructure that recognizes the marker. - Concept introduced, change history as a per-entity opt-in.
[Rubric §30, Compliance, Privacy & Data Governance](assesses whether a system can answer "who changed this, and when" for the records where someone will actually ask) and[Rubric §31, Cost/FinOps](assesses whether a capability's running cost is a decision rather than a default). The doc comment makes the trade-off explicit (IAuditedEntity.cs:9-14): a trail is one row per changed property per save, so trailing every entity multiplies write volume and storage without anyone asking for it. Marking entities one at a time makes that volume deliberate: trail the aggregates whose history will be demanded (an order, a permission grant, a ticket) and leave high-churn bookkeeping tables alone. This is also[Rubric §8, Data Architecture], since the trail rows are written in the same transaction as the change they describe. - Walkthrough: the type itself has no members; the four remarks paragraphs are the contract.
- Marker, not a global default (
IAuditedEntity.cs:9-14): the volume rationale above. - Composes with
IAuditableEntitybut does not require it (IAuditedEntity.cs:16-21): the two answer different questions.IAuditableEntitystamps the CURRENT state (who touched this row last); this marker records the SEQUENCE that produced it. An entity may carry either, both, or neither, and when both are present the trail rows see the freshly stamped values because capture runs after the stamping interceptor. - Recording is host-gated (
IAuditedEntity.cs:22-26): nothing is written unless the host calledAddAuditTrail(configuration)and setAuditTrail:Enabled. Marking an entity in a host that never opted in is inert: no table, no rows, no cost. - Personal data is redacted at capture (
IAuditedEntity.cs:27-32): a property marked withPiiAttributerecords a redaction placeholder on both sides of the change, so the trail never becomes a second, unerasable copy of a data subject's personal data (ADR-005).
- Marker, not a global default (
- Why it's built this way: an empty interface is the cheapest thing a domain type can declare that infrastructure can key off, and it keeps the policy ("this aggregate's history matters") in the domain while the mechanism stays in EF Core. The decision is recorded in ADR-075.
- Where it's used: the recognizer is
AuditTrailSaveChangesInterceptor(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:62), whoseShouldAuditpredicate isentry.Entity is IAuditedEntityplus a framework-entity exclusion and a "being written" state check (AuditTrailSaveChangesInterceptor.cs:225-228, applied to the change-tracker entries at:197); it writesAuditTrailEntryrows and is registered last, after the audit and domain-event interceptors, so it diffs final values (AuditTrailSaveChangesInterceptor.cs:26-29). The host gate isAddAuditTrail(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:476) plusAuditTrailSettings.Enabled(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSettings.cs:26, an uninitializedbooland thereforefalseunless configured). Marked aggregates today: ADC'sUser(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:35, rationale atUser.cs:27-30),Event(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:24),Session(.../Sessions/Session.cs:22),Speaker(.../Speakers/Speaker.cs:22),CheckIn(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Domain/CheckIns/CheckIn.cs:28),PointsEntry(.../Points/PointsEntry.cs:31), and Helpdesk'sTicket(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Domain/Tickets/Ticket.cs:26); the childTicketCommentdeliberately does NOT carry it, and says so (MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Domain/Tickets/TicketComment.cs:12,16). The opting-in hosts are the three ADC services (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:232,MMCA.ADC.Conference.Service/Program.cs:297,MMCA.ADC.Engagement.Service/Program.cs:198) and the Helpdesk web host (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:78). - Caveats / not-in-source: retention is not automatic.
AuditTrailSettings.RetentionDaysdefaults to 90 (AuditTrailSettings.cs:38), but the doc comment states the purge only happens if the host also runs the scheduler; without it the trail still records and the table grows until an operator prunes it (AuditTrailSettings.cs:32-36).
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 (IBaseEntity.cs:11).init(set at construction, immutable after) encodes "an entity's identity is assigned once and never changes", and 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
(ADR-048); the
contract is intentionally tiny so the concrete base classes
(
BaseEntity<TIdentifierType>toAuditableBaseEntity<TIdentifierType>toAuditableAggregateRootEntity<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), whoseIsIdValueGeneratedextension property is a singleGetCustomAttribute<IdValueGeneratedAttribute>() is not nullcheck (MMCA.Common/Source/Core/MMCA.Common.Domain/Extensions/EntityTypeExtensions.cs:19), and consumed by the EF entity-configuration base to decide the key's value-generation strategy (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfiguration.cs:61) as well as by entity factory methods deciding whether to setId(for example ADC'sUser, marked[IdValueGenerated]atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:33).
IHasOrderingKey
MMCA.Common.Domain ·
MMCA.Common.Domain.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IHasOrderingKey.cs:24· Level 0 · interface
- What it is: an opt-in contract for a domain or integration event that must be delivered in
order relative to other events sharing the same key. One member,
string? OrderingKey(IHasOrderingKey.cs:30), returning a value that identifies the entity whose event stream must stay sequential, typically the aggregate id. - Depends on: nothing first-party. Implemented on an event record, most often a
BaseIntegrationEvent. - Concept introduced, per-key ordered delivery over an at-least-once outbox.
[Rubric §6, CQRS & Event-Driven](assesses whether event delivery guarantees are explicit) and[Rubric §29, Resilience & Business Continuity](assesses what happens when one message keeps failing). The outbox's default is unordered and fully parallel: rows are claimed and dispatched independently, which is what makes it fast and horizontally scalable, but it means two events raised from the same aggregate can reach the bus out of order. This interface buys ordering back per key, and the doc comment is unusually explicit about the price (IHasOrderingKey.cs:15-22): a keyed row that is failing and backing off blocks every later row with the same key until it succeeds or exhausts its retries. That is head-of-line blocking by design. Keys must therefore be as narrow as the requirement really is: one key per aggregate serializes that aggregate only, while a constant key serializes the whole outbox. - Walkthrough
- The summary (
IHasOrderingKey.cs:3-8) fixes the usage shape: implement on the event record and return something like"order-1042"or$"cart-{CartId}". - The enforcement paragraph (
:9-14) states where ordering lives: the outbox copies the value onto the row it writes, and the processor refuses to claim a row while an EARLIER unprocessed, non-dead-lettered row carrying the same key exists in the same data source, so ordering holds across batches and across scaled-out processor replicas, not merely within one batch. OrderingKeyisstring?and the nullability is meaningful (:26-30): returningnullopts that individual event instance out of ordered delivery even though its type implements the interface. That is why the framework does an instance-level interface test rather than a type-level flag, and the code comment at the copy site says so (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/OutboxMessage.cs:113-116).
- The summary (
- Why it's built this way: ordering is enforced at claim time rather than at fetch time, which
is what makes it survive batching and scale-out
(
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:441-448). Making it opt-in per event keeps the unordered fast path free: a batch containing no keyed row runs exactly the query it always ran, with no subquery for the optimizer to prove away (OutboxProcessor.cs:472-477). The decision is recorded in ADR-003 (003-outbox-dual-dispatch.md:142-157). - Where it's used:
OutboxMessagecopies the key onto the row it writes (OutboxMessage.cs:86for the column,:115insideFromDomainEventat:98).OutboxProcessorenforces it in two places:SelectOrderedCandidates(OutboxProcessor.cs:509-526) keeps at most one row per key in this cycle's candidate set (:516), andFilterUnblocked(OutboxProcessor.cs:546-556) adds theNOT EXISTSpredicate to the claim update itself, so a second replica racing the same key loses on the row rather than on a check made before the race (:550-554; the retry-count conjunct at:552is what lets a dead-lettered predecessor stop blocking). The storage side is configured onApplicationDbContext: a 200-character non-Unicode column (.../Persistence/DbContexts/ApplicationDbContext.cs:538) and the filteredIX_OutboxMessages_Orderingindex over(OrderingKey, OccurredOn), which stays empty for hosts that never declare a key (ApplicationDbContext.cs:554-562). Covered byOutboxProcessorOrderingTests(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Outbox/Processing/OutboxProcessorOrderingTests.cs:101,181,197) andOutboxMessageTests(.../OutboxMessageTests.cs:111,119). - Caveats / not-in-source: no event in ADC, Store or Helpdesk implements this interface today; the
only implementors in the workspace are test doubles
(
MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/Outbox/OutboxMessageTests.cs:20and.../DomainEventSaveChangesInterceptorOutboxRoutingTests.cs:284). The capability is shipped and tested, not exercised by an application event. Ordering is also not total under a timestamp tie: the predecessor test is onOccurredOnalone, so two rows sharing a key and an exact timestamp are ordered within a cycle byIdbut neither blocks the other in SQL, which the code states as a deliberate non-guarantee (OutboxProcessor.cs:450-455).
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/Persistence/IRepository.cs:399-403). The interesting design point is why the token needs its own interface at all: the repository's aggregate-typed overloadSetOriginalRowVersion(TEntity, byte[])(IRepository.cs:406) 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:417) 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:53), so every auditable entity (aggregate roots and their children) satisfies it. Consumed byIRepository<TEntity, TIdentifierType>(IRepository.cs:417) and implemented inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:86-93, which casts the child toobject, walks to_context.Entry(...).Property(nameof(AuditableBaseEntity<>.RowVersion))and assignsOriginalValue; the decorator forwards both overloads unchanged (.../EFRepositoryDecorator.cs:41-46). - Caveats / not-in-source: both
SetOriginalRowVersionoverloads reject a null token outright withArgumentNullException.ThrowIfNull(EFRepository.cs:77-78,88-89), but neither rejects an empty array:[]is assigned as the original value like any other token. Whether an empty token can ever match a real SQL Serverrowversionis not determinable from source here; it is decided by the provider's comparison, not by this code.
ITenantEntity
MMCA.Common.Domain ·
MMCA.Common.Domain.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/ITenantEntity.cs:33· Level 0 · interface
- What it is: an opt-in marker plus one property. An entity that carries it belongs to exactly one tenant, and every read and write the framework performs on it is scoped to the tenant resolved for the current request.
- Depends on: nothing first-party; the single member is a
stringgetter (ITenantEntity.cs:39). - Concept introduced, shared-schema multi-tenancy declared in the domain.
[Rubric §11, Security](assesses whether one customer's data can be read or written by another) and[Rubric §8, Data Architecture](assesses how isolation is expressed in the model rather than remembered by every query author). Shared-schema tenancy means all tenants live in the same tables and a discriminator column keeps them apart. The risk of that model is a forgottenWHERE TenantId = ..., so the framework never asks a caller to write one: marking the entity is the whole opt-in, and both sides are enforced by infrastructure.[Rubric §30, Compliance, Privacy & Data Governance]also applies, since tenant boundaries are usually a contractual data-segregation commitment. - Walkthrough: one getter,
string TenantId { get; }(ITenantEntity.cs:35-39). Four remarks paragraphs carry the contract.- Reads (
ITenantEntity.cs:8-14): a namedTenantglobal query filter is applied to every non-owned entity carrying the interface, alongside the existingSoftDeletefilter; named filters compose with AND, so a tenant never sees another tenant's rows and never sees soft-deleted ones. When no tenant is resolved (a background service, a seeder, an admin flow) the filter is inert and the query sees every tenant's rows: the system context is deliberately unrestricted. - Writes (
ITenantEntity.cs:15-20): the interceptor stampsTenantIdon insert and refuses a save that would write across the boundary, which is why the property is read-only on the domain type (the value is not a caller's to choose; EF writes it through the backing field). - A
stringcapped at 64 characters (ITenantEntity.cs:21-25): the identifier arrives from a claim, a header, or configuration, all of which are strings, so a stronger domain type would only add a conversion at every boundary without adding a guarantee. The cap is enforced at the model (TenantIdMaxLength = 64atMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:397, applied viaIsRequired().HasMaxLength(...).IsUnicode(false)atApplicationDbContext.cs:445-448). - Marking is host-gated in practice (
ITenantEntity.cs:26-31): a host that never resolves a tenant behaves exactly as it did before. Adopting tenancy is marking entities, callingAddMultiTenancy(configuration), and settingTenancy:Enabled.
- Reads (
- Why it's built this way: putting the marker in Domain and the enforcement in Infrastructure keeps
the model framework-free while making isolation impossible to forget, which is the
ADR-073 decision. Splitting read
enforcement (query filter) from write enforcement (interceptor) is deliberate: the interceptor's doc
comment notes that a caller who bypasses the filter with EF's parameterless
IgnoreQueryFilters()can read across tenants but still cannot write across them (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/TenantSaveChangesInterceptor.cs:29-34). - Where it's used: the read side is
ApplicationDbContext.ApplyTenantFilters(ApplicationDbContext.cs:428-488, called fromOnModelCreatingat:334), which selects every non-ownedITenantEntitytype (:439-441), indexes the discriminator on non-Cosmos engines, widening it to(TenantId, IsDeleted)when the entity is also auditable (:457-466), and installs the named filterCurrentTenantId == null || EF.Property<string>(e, "TenantId") == CurrentTenantId(:480-486; the filter-name and property-name constants are at:391,394, and the ambient value is read off the context at:119). The write side isTenantSaveChangesInterceptor(TenantSaveChangesInterceptor.cs:36), which reads the tenant once per save (:68), walksChangeTracker.Entries<ITenantEntity>()skipping owned types (:74-75), and routes each entry by state (:77-93); an untenanted insert from an untenanted scope throwsCrossTenantWriteExceptionrather than writing a row nobody can read (:101-110), and a declared-versus-current mismatch is rejected the same way (:123). The host gate isAddMultiTenancy(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:525) plusTenancySettings(Tenancy:EnabledatMMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Tenancy/TenancySettings.cs:67, claim-then-header resolution order atTenancySettings.cs:57). The only entities marked in the workspace apps today are Helpdesk'sTicketand its childTicketComment(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Domain/Tickets/Ticket.cs:26and.../TicketComment.cs:16), opted in atMMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:80; ADC and Store carry noITenantEntitytoday. - Caveats / not-in-source:
Tenancy:Enabledgates resolution, not isolation. The filter and the interceptor are always registered and are inert whenever no tenant is resolved (TenancySettings.cs:37-39, and the registration note atDependencyInjection.cs:549), so an untenanted code path (a job, a seeder) reads every tenant's rows by design. That is the documented behavior, not an oversight, but it means "tenant safety" is a property of the request pipeline resolving a tenant, not of the entity marker alone.
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. Mechanism (1) is[Rubric §34, Architecture Governance & Documentation], a rule enforced by an executable fitness function rather than prose; mechanism (2) is a real, unit-tested helper. Note what is and is not automatic: no logging or destructuring policy routes entities throughPiiRedactor.Redactin production (only tests call it), so the §13 "PII out of logs" control is available and tested but opt-in per call site. The marker does have a production reader beyond the fitness test, though: the audit-trail interceptor consults it on every captured change (see Where it's used). - 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 the erasure fitness
test,
PiiRedactorand the audit trail all find 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 ADC Identity
Useraggregate:Email,FirstName,LastNameandAvatarUrl(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:38,42,46,105);UserreachesIAnonymizablethroughIErasableUser, which extends it (User.cs:35andMMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:30). The erasure detection lives once in the sharedMMCA.Common.Testing.Architecturepackage: theEntitiesWithPiiImplementAnonymizablerule (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Governance/ArchitectureRules.Governance.cs:11-21) 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/Governance/PiiConventionTestsBase.cs:7) that just passes itsIArchitectureMap:MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/PiiConventionTests.cs:13(the scan is structurally vacuous today, the framework Domain ships no data-subject type),MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Governance/PiiConventionTests.cs:3, andMMCA.Store/Tests/Architecture/MMCA.Store.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/Governance/PiiErasureContractFitnessTests.cs:19), which forces a representative[Pii]-carrying sample through both halves end to end (recognized and masked byPiiRedactor, then erased idempotently viaIAnonymizable). Two reflective readers exist in shipped code:PiiRedactoritself (IsDefined(typeof(PiiAttribute), inherit: false),MMCA.Common/Source/Core/MMCA.Common.Domain/Privacy/PiiRedactor.cs:119) andAuditTrailSaveChangesInterceptor, which short-circuits per type withPiiRedactor.HasPii(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:286), caches the per-property verdict (:492-504), and writesPiiRedactor.RedactedTokeninto both the old and new value columns of a change row (:309-310). - Caveats / not-in-source: the interceptor's per-property check only sees properties EF maps to a
real CLR member. A shadow property has no
PropertyInfoand therefore cannot carry the attribute, so it is never treated as personal data (AuditTrailSaveChangesInterceptor.cs:488-495).
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:3-8) 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: four members (
IAggregateRoot.cs:12-32):IReadOnlyCollection<IDomainEvent> DomainEvents { get; }, the pending event queue (read-only from outside, line 12);void AddDomainEvent(IDomainEvent), called by the aggregate's own methods to record that something happened (line 16);void ClearDomainEvents(), the wholesale reset (line 19); andvoid RemoveDomainEvents(IEnumerable<IDomainEvent>)(line 32), which removes only the events a caller captured. That last member exists to fix a real delivery hole, spelled out in its remarks (IAggregateRoot.cs:25-31): the persistence pipeline captures an aggregate's events before saving and clears them afterwards, so clearing wholesale would discard anything a handler raised on the same aggregate during in-process dispatch (those events arrive after the capture and would be wiped before any later capture could see them, so they would never dispatch and never reach the outbox).[Rubric §8, Data Architecture](SaveChanges flow): the sequence is aggregate mutates state, callsAddDomainEvent, EF saves data and serializes events to the outbox in the same DB transaction, the captured events are removed, and the 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/remove/clear methods means only the aggregate's own behavior can raise events and only infrastructure can retire them after a successful save, preserving the at-least-once outbox contract (ADR-003).
- Where it's used: implemented by
AuditableAggregateRootEntity<TIdentifierType>(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:13), which backs it with a privateList<IDomainEvent>(line 16), exposes it asDomainEvents(line 18), and implementsAddDomainEvent(line 24),ClearDomainEvents(line 34) andRemoveDomainEvents(lines 37-50, matching by reference equality so two structurally equal events raised separately stay two distinct occurrences, lines 41-43); every aggregate in both apps inherits from that. Discovered by theDomainEventSaveChangesInterceptorduring persistence, whoseClearDomainEvents(CapturedState)helper retires exactly what it captured by callingRemoveDomainEventsper capture (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:356-365), on the deferred-transaction path (:312) and after in-process dispatch (:331).
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 to 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: two of its members have a production consumer.
AuditTrailSaveChangesInterceptorcallsHasPiionce per changed entity type (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/AuditTrail/AuditTrailSaveChangesInterceptor.cs:286) and writesRedactedTokeninto both theOldValueandNewValuecolumns of a[Pii]property's change row (:309-310), which is how the change history avoids becoming a second copy of personal data (ADR-075); that behavior is asserted inAuditTrailSaveChangesInterceptorTests(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Persistence/AuditTrail/AuditTrailSaveChangesInterceptorTests.cs:180-181). It is unit-verified byPiiRedactorTests(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/Governance/PiiErasureContractFitnessTests.cs:19). - Caveats / not-in-source:
RedactandRedactToStringhave no production call site; only tests invoke them (PiiRedactorTests.cs:35,46,53,58,68andPiiErasureContractFitnessTests.cs:29,42,46,69), so the log-side control is ready and tested but opt-in per call site rather than an automatic pipeline stage. Redaction is also 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-12).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 12-15), so foreign keys and the audit trail survive. The row stays; 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/Rules/Governance/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]impliesIAnonymizablefitness rule guarantees no PII-holding entity silently lacks an erasure path (ADR-005). - Where it's used: satisfied by the ADC Identity
Useraggregate, which holds the four[Pii]fieldsEmail/FirstName/LastName/AvatarUrl(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:38,42,46,105) and declaresIErasableUser(User.cs:35), which extendsIAnonymizable(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IErasableUser.cs:30); the placement of that interface onUseritself is load-bearing, becauseUser.Deletehides the base soft-delete withnew(User.cs:341, rationale atUser.cs:22-25), and the implementation isUser.AnonymizeatUser.cs:363. Enforced byPiiConventionTests(G25), and exercised together withPiiRedactorbyPiiErasureContractFitnessTests(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/PiiErasureContractFitnessTests.cs:19).
IReactivatable
MMCA.Common.Domain ·
MMCA.Common.Domain.Interfaces·MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IReactivatable.cs:19· Level 3 · interface
- What it is: a single-method contract (
Result Reactivate()) that a soft-deletable entity implements to publish that it may be brought back into the visible set (BR-135). - Depends on:
Result(viaMMCA.Common.Shared.Abstractions,IReactivatable.cs:1). - Concept introduced, capability opt-in over base-class inheritance.
[Rubric §4, DDD](assesses whether the model expresses business rules rather than technical convenience) and[Rubric §1, SOLID](interface segregation: a capability nobody needs is not forced on every entity). The mechanism is a deliberate visibility choice, and the doc comment states it (IReactivatable.cs:5-11):AuditableBaseEntity.Undelete()isprotected, not public (MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:89), so reversing a soft delete is a decision each entity makes for itself. Implementing this interface is how an entity publishes that decision, typically as the one-linerpublic Result Reactivate() => Undelete();. The contrast with soft-delete is the teaching point: every auditable entity can be deleted, but only the ones that say so can come back. - Walkthrough
Reactivate()(IReactivatable.cs:25) returnsResult, notvoid, because the operation has a real failure mode: the entity is not deleted. The baseUndelete()supplies exactly that guard, returningError.Invariant("Entity.NotDeleted", ...)whenIsDeletedis alreadyfalse(AuditableBaseEntity.cs:91-99) and flipping the flag and returning success otherwise (AuditableBaseEntity.cs:101-103). The audit interceptor clearsDeletedOn/DeletedByon the next save (AuditableBaseEntity.cs:85-86).- The second paragraph of the doc (
IReactivatable.cs:10-17) records the constraint that gives the interface teeth: the aggregate helperAuditableAggregateRootEntity<TIdentifierType>.RestoreChild<TChild, TChildId>constrains its child towhere TChild : AuditableBaseEntity<TChildId>, IReactivatable(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:212-218). A child that does not implement the interface simply cannot be passed to the helper: resurrection is a business decision per entity, not a capability the base class hands out to every soft-deletable row.
- Why it's built this way:
RestoreChildshows the payoff. It checks only the framework-level rule ("this candidate is soft-deleted",AuditableAggregateRootEntity.cs:223-231), delegates the entity's own rule tochild.Reactivate()and propagates its failure verbatim (:233-237), then re-adds the child to the aggregate's collection only when it is not already there, because a caller who resolved the child through anignoreQueryFiltersread holds an instance the loaded collection never contained (:243-245). Ownership checks and field re-validation stay in the calling aggregate method, which runs them BEFORE the helper so a rejected restore leaves the child untouched and still deleted (:191-195). Soft-delete as the default deletion model, and undelete (BR-135) as one of its motivations, is recorded in ADR-005. - Where it's used: five ADC Conference child entities implement it, each as the one-line delegation
to
Undelete():Room(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:13, implementation atRoom.cs:141),EventSpeaker(.../Events/EventSpeaker.cs:14),SessionSpeaker(.../Sessions/SessionSpeaker.cs:14, implementation atSessionSpeaker.cs:57),SessionCategoryItem(.../Sessions/SessionCategoryItem.cs:14), andSpeakerCategoryItem(.../Speakers/SpeakerCategoryItem.cs:14). The helper path is covered byAuditableAggregateRootEntityAdditionalTests, which defines its own reactivatable child double (MMCA.Common/Tests/Core/MMCA.Common.Domain.Tests/Entities/AuditableAggregateRootEntityAdditionalTests.cs:25). - Caveats / not-in-source: nothing stops an implementer from writing a
Reactivate()that does not callUndelete(). The interface constrains the shape (aResult-returning, idempotent-friendly member), not the implementation; no fitness test asserts the delegation.
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 the value-object family, declared as a single line:
public abstract record ValueObject;(ValueObject.cs:8). - Depends on: nothing first-party. Not even a namespace import: the file is a namespace declaration, a doc comment, and the type.
- 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 are the same row. By inheriting fromrecord, every value object gets compiler-generated structural equality (Equals/GetHashCodeover all declared properties) plus non-destructivewithmutation for free; the doc comment (ValueObject.cs:3-7) states exactly that rationale. This is the cheapest possible base: it adds a type (so code and fitness tests can say "this is a value object") without adding a single member. - Walkthrough: there are no members. The whole contract is "be a record, be abstract, be named
ValueObject". All the work happens in the derived types below. - 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 behaviour.[Rubric §34, Architecture Governance](assesses whether rules are executable rather than aspirational): the family is policed by the fitness ruleArchitectureRules.ValueObjectsAreImmutableSealedInShared(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Domain/ArchitectureRules.Immutability.cs:56), driven fromImmutabilityTestsBase.ValueObjects_ShouldBe_ImmutableSealedAndInShared(.../Bases/ImmutabilityTestsBase.cs:25), which every repo subclasses. That rule is why every derivative below is asealed recordliving in the Shared layer. - Where it's used: base of
Address,Currency,DateRange,DateTimeRange,Email,Money,PhoneNumber. Each adds a static factory returningResult<T>so an invalid value object cannot be constructed. Note the deliberate non-member:Enumeration<TEnumeration>does not derive fromValueObject(Enumeration.cs:24-29explains why), because the sealed-record rule above would forbid the static-member idiom that type exists for. - Caveats / not-in-source: equality is purely structural; if a future value object held a mutable collection, record equality would compare references, not contents. None of the current ones do.
BaseEntity<TIdentifierType>
MMCA.Common.Domain ·
MMCA.Common.Domain.Entities·MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/BaseEntity.cs:34· Level 1 · class (abstract)
- What it is: the concrete root of the entity hierarchy. It implements
IBaseEntity<TIdentifierType>with a singlerequired initIdand supplies identity equality (==,!=,Equals,GetHashCode) for every entity in both applications. - Depends on:
IBaseEntity<TIdentifierType>(Level 0). Externals are BCL only:EqualityComparer<T>,HashCode, andSuppressMessageAttribute. - Concept introduced, entity identity equality.
[Rubric §4, Domain-Driven Design](assesses whether the model distinguishes entities from value objects: an entity is defined by its identifier over time, a value object by its contents). The contrast withValueObjecttwo levels up is the whole point: value objects compare by value, entities compare by id. The class states it in the remarks (BaseEntity.cs:16-18): two instances are equal when they are the same concrete type and carry the same assignedId, so the same row loaded twice through two different contexts compares equal instead of answering the reference comparison the CLR would give by default.
The second half of the concept is transience. An entity whoseIdis still the identifier type's default (zero for anintalias,nullfor a reference alias) has not been identified yet, which is exactly the state of anIdValueGeneratedAttributeentity before the database stamps its key. Two such instances are equal only when they are the same reference (BaseEntity.cs:20-24), because a default id means "not identified yet", not "identified as zero".[Rubric §1, SOLID](assesses substitutability among other things): the type guardother.GetType() == GetType()(BaseEntity.cs:74) is what keeps the equality contract symmetric under inheritance. A derived entity never compares equal to its base or to a sibling type.[Rubric §15, Best Practices & Code Quality](assesses whether analyzer suppressions are scoped and justified rather than blanket): the one suppression here, S3875 onoperator ==(BaseEntity.cs:47-50), carries a paragraph of justification explaining why the rule's own escape hatch (IEquatable<T>) is deliberately not taken. - Walkthrough
public required TIdentifierType Id { get; init; }(BaseEntity.cs:37):requiredmeans a factory method cannot forget to set it;initmeans nothing can change it afterwards. Both construction paths land here, an application factory setting the value explicitly and EF Core materializing an existing row (BaseEntity.cs:7-9).operator ==/operator !=(BaseEntity.cs:51-52,BaseEntity.cs:60-61): the==operator treatsnullas equal only tonulland otherwise delegates straight toEquals, so there is exactly one equality implementation rather than two that can drift.Equals(object?)(BaseEntity.cs:71-77): four conjoined guards after the reference check, the type isBaseEntity<TIdentifierType>, the concrete types match, both ids are assigned, and the ids compare equal underEqualityComparer<TIdentifierType>.Default.GetHashCode()(BaseEntity.cs:93):HashCode.Combine(GetType(), Id), matchingEqualsfor any entity that already has an id.HasAssignedId(TIdentifierType id)(BaseEntity.cs:102-103): the private test, expressed as "not equal todefault" through the default comparer so it handles both shapes an identifier alias can take (zero for an integer key,nullfor a reference key).
- Why it's built this way: the class deliberately does not implement
IEquatable<T>(BaseEntity.cs:26-31). An unsealedIEquatable<T>breaks the equality contract for subclasses (Sonar S4035), so equality is provided through the type-guardedobject.Equalsoverride instead, and a sealed derived entity may layer a strongly-typedIEquatable<TSelf>on top. The same trade-off is documented for the same reason onEnumeration<TEnumeration>andRoleValue. - Where it's used: base of
AuditableBaseEntity<TIdentifierType>and, transitively, ofAuditableAggregateRootEntity<TIdentifierType>; every domain entity in MMCA.ADC, MMCA.Store, and MMCA.Helpdesk inherits from one of those two. The aggregate helpersAuditableAggregateRootEntity<TIdentifierType>.GetChildOrNotFoundandRestoreChildrely on thisIdequality for their in-memory child lookups. - Caveats / not-in-source: the hash changes when a database-generated key is stamped
(
BaseEntity.cs:84-90), so an[IdValueGenerated]entity must not be put into aHashSet<T>or used as a dictionary key before the save that assigns its id, or it becomes unfindable in its own collection afterwards. Code that has to track pre-save instances keys them by reference instead:DomainEventSaveChangesInterceptorbuilds its exclusion set withReferenceEqualityComparer.Instance(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:187), andRemoveDomainEventsdoes the same (MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:43).
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: a 21-line static class that adds one computed property,
IsIdValueGenerated, toSystem.Type, answering "does this entity let the database assign its key?". - Depends on:
IdValueGeneratedAttribute(Level 0) andSystem.Reflectionfrom the BCL. - Concept reinforced, C#
extension(T)members. Theextension(Type entityType)block (EntityTypeExtensions.cs:11) is the same preview language feature introduced byDomainHelper; here it is used to hang a property (not a method) off a type the framework does not own, so call sites readtypeof(Ticket).IsIdValueGeneratedrather thanEntityTypeExtensions.IsIdValueGenerated(typeof(Ticket)).[Rubric §8, Data Architecture](assesses whether key-generation strategy is a deliberate, declared decision rather than an accident of configuration): the strategy is declared once, as an attribute on the entity, and this property is the single reader of that declaration.[Rubric §15, Best Practices & Code Quality]: the reflection call lives in exactly one place, so a change in how the strategy is declared is a one-file change. - Walkthrough: the whole implementation is one expression-bodied property
(
EntityTypeExtensions.cs:19):entityType.GetCustomAttribute<IdValueGeneratedAttribute>() is not null. There is no caching layer in this file; the doc comment (EntityTypeExtensions.cs:13-18) states the intended caller, a factory method deciding at runtime whether to assign an explicit id or passdefault. - Why it's built this way: a factory cannot ask EF Core what the key strategy is (the Domain
layer sits below Infrastructure and has no
DbContext), so the declaration has to live on the domain type itself. An attribute plus a reflection reader keeps the Domain layer self-contained and keeps the strategy visible on the entity where a reader of the code will look for it. - Where it's used: entity factory methods across every consumer, always in the same shape,
bool isIdValueGenerated = typeof(T).IsIdValueGenerated;immediately before building the entity:MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Domain/Orders/Order.cs:107,MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Domain/Orders/OrderLine.cs:67,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Customers/Customer.cs:88, andMMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.Domain/Tickets/Ticket.cs:74. Covered byEntityTypeExtensionsTests(MMCA.Common/Tests/Core/MMCA.Common.Domain.Tests/Extensions/EntityTypeExtensionsTests.cs:16).
Address
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Contact·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/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 nullable to accommodate international formats (Address.cs:18-40). - Depends on:
AddressInvariants(mutual, see the cycle note),Result,ValueObject, plusSystem.Runtime.SerializationandSystem.Text.Json.Serializationfrom the BCL. - Concept introduced, the value-object factory method returning
Result<T>.[Rubric §4, Domain-Driven Design](value objects with enforced invariants) and[Rubric §15, Best Practices & Code Quality](unconstructable invalid state). The pattern: the constructor isprivate(Address.cs:43), so nothing outside the type can callnew Address(...). The only public entry point isstatic Result<Address> Create(...)(Address.cs:69-89). Pass the invariants and you getResult.Success(new Address(...)); fail and you getResult.Failure<Address>(result.Errors). An invalidAddressis therefore unconstructable. The[JsonConstructor]on the private constructor (Address.cs:42) is the one sanctioned exception:System.Text.Jsonmay round-trip an object whose fields were already validated on the way in.[DataContract](Address.cs:15) plus[DataMember(Order = 1..6)]on each property (Address.cs:19,23,27,31,35,39) pin the wire shape and field order for the XML formatter, so the contract stays stable across releases.[Rubric §9, API & Contract Design]. - Documented cycle,
AddressandAddressInvariants. Both types sit at Level 3 in the same strongly connected component.Address.CreatecallsAddressInvariants.EnsureAddressLine1IsValid(Address.cs:78), whileAddressInvariants.EnsureAddressIsValidtakes anAddress?parameter (AddressInvariants.cs:35). This is deliberate: the invariant helper is the canonical home for the constraints (max-length constants, error codes), the value object owns construction. Because each references the other, the levelling algorithm assigns them the same level rather than an impossible ordering.[Rubric §2, Design Patterns](mutual delegation is acceptable here; neither type owns the other's core identity). - Walkthrough
Create(...)(Address.cs:69): wraps the single check inResult.Combine(...)(Address.cs:77-78) so more invariants can be added without restructuring the method, then returnsResult.Failure<Address>(result.Errors)on failure (Address.cs:80). OnlyAddressLine1is enforced at the value-object level; the optional fields are length-checked by the FluentValidation rules in the Application layer.ToString()(Address.cs:93-104): joins the non-empty parts with", "viastring.Join+Where(part => !string.IsNullOrEmpty(part)), producing a single human-readable line.
- Why it's built this way: EF Core stores
Addressas an owned type viaOwnsOne, stated in the remarks (Address.cs:12-14) and done for real inMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/EntityConfiguration/CustomerConfiguration.cs:43, which flattens the six properties intoAddressLine1,AddressLine2,AddressCity,AddressState,AddressZipCode,AddressCountrycolumns on theCustomertable rather than a child table. Owned types have value semantics at the persistence level, which is exactly the domain semantic. - Where it's used: the Store Identity
Customeraggregate owns one (configuration cited above, with everyHasMaxLengthreading anAddressInvariantsconstant,CustomerConfiguration.cs:47-73);RegisterRequestcarries an optionalAddress? Address = null(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/RegisterRequest.cs:18); theAddressLine1Rules<T>family andAddressValidatorvalidate the request-side shape.
AddressInvariants
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Contact·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/AddressInvariants.cs:9· Level 3 · class (static)
- What it is: a static helper holding the address field max-length constants (shared by EF
configurations and FluentValidation validators) plus two invariant checks that return
Result. - Depends on:
Address(mutual cycle, same SCC),Error,Result. - Concept introduced, the shared-constants invariant class.
[Rubric §34, Architecture Governance & Documentation & Evolvability](assesses whether a constraint has exactly one place to change). Sixpublic static readonly intconstants are declared (AddressInvariants.cs:12-27):AddressLine1MaxLength = 200,AddressLine2MaxLength = 200,CityMaxLength = 100,StateMaxLength = 100,ZipCodeMaxLength = 20,CountryMaxLength = 100. EF entity configurations, FluentValidation rules, and these invariants all read the same fields, so changing a limit in one place propagates to schema and validation together. This is the shape repeated for every validated value type in this group: the invariant class is the single source of truth for both the constraint value and the error identity. - Walkthrough
EnsureAddressIsValid(Address? address, string source)(AddressInvariants.cs:35): returnsResult.Success()for anulladdress (an address is optional on many entities,AddressInvariants.cs:37-38), otherwise delegates to the line-1 check throughResult.Combine.EnsureAddressLine1IsValid(string addressLine1, string source)(AddressInvariants.cs:50): an expression-bodied conditional overstring.IsNullOrWhiteSpace; on failure it returnsError.Invariant(code: "Address.Line1.Empty", ...)(AddressInvariants.cs:52-56) carrying the caller's method name assourceandnameof(addressLine1)astarget, which is how failures stay traceable without a stack trace.
- Why it's built this way: keeping the constants out of the value object lets an EF configuration
reference
AddressInvariants.AddressLine1MaxLengthwithout depending onAddressitself, keeping the Infrastructure-to-Shared coupling thin.[Rubric §3, Clean Architecture]. - Where it's used: called from
Address.Create(Address.cs:78); every max-length constant is read byCustomerConfigurationin Store Identity (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/EntityConfiguration/CustomerConfiguration.cs:47-73) and by theAddressLine1Rules<T>family in the Application layer.
EmailInvariants
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Contact·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/EmailInvariants.cs:11· Level 3 · class (static, partial)
- What it is: the invariant checks for email addresses (not empty, at most 256 characters, and a
practical format check) plus the shared
MaxLengthconstant. - Depends on:
Error,Result, andSystem.Text.RegularExpressions. - Concept introduced,
[GeneratedRegex]for compile-time regex.[Rubric §12, Performance & Scalability](assesses avoided allocations and avoided per-call work). The class isstatic partial(EmailInvariants.cs:11) so the source generator can implement the partial propertyEmailRegexdeclared atEmailInvariants.cs:55-56. The attribute[GeneratedRegex(@"^[^@\s]+@[^@\s]+\.[^@\s]+$", RegexOptions.None, matchTimeoutMilliseconds: 1000)]bakes a compiled matcher at build time (nonew Regex(...)at runtime) and the 1000 ms match timeout caps any catastrophic-backtracking exposure, which is the[Rubric §11, Security]angle on a user-supplied string. The pattern is deliberately practical rather than full RFC 5322; the doc comment (EmailInvariants.cs:16-18) says so plainly instead of overselling it. - Walkthrough:
EnsureEmailIsValid(string email, string source)(EmailInvariants.cs:23) runs three sequential guards, each returning a distinct error code so a caller can tell the failures apart:"Email.Empty"onstring.IsNullOrWhiteSpace(EmailInvariants.cs:25-32),"Email.TooLong"pastMaxLengthwith the limit interpolated throughstring.Create(CultureInfo.InvariantCulture, ...)so the message never picks up an ambient locale (EmailInvariants.cs:34-41), and"Email.InvalidFormat"on a regex miss (EmailInvariants.cs:43-50). All three useError.Invariantrather thanError.Validation, marking them as domain-level data-integrity rules. - Why it's built this way:
MaxLength = 256(EmailInvariants.cs:14) is onestatic readonlyfield referenced by EFHasMaxLengthcalls and FluentValidation rules alike, the same single-source-of-truth idea asAddressInvariants. - Where it's used: called by
Email.Create(Email.cs:34) and by the email rule helpers in the Application validation layer.
Enumeration<TEnumeration>
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:71· Level 3 · class (abstract, generic)
- What it is: the abstract base for a smart enumeration: a closed set of named, integer-valued
members declared on the derived type as
public static readonlyfields. Unlike a CLRenum, each member is a real object, so behaviour (policies, rates, display rules) can hang off it instead of living in aswitchsomewhere else (Enumeration.cs:13-18). - Depends on:
Error,Result, andEnumerationJsonConverterFactory(mutual: the base carries[JsonConverter(typeof(EnumerationJsonConverterFactory))]atEnumeration.cs:66while the factory is constrained onEnumeration<T>). Externals:System.Collections.Frozen,System.Collections.ObjectModel,System.Reflection,System.Text.Json. - Concept introduced, the self-referencing generic (curiously recurring) constraint.
[Rubric §4, DDD](a closed domain vocabulary that carries behaviour) and[Rubric §1, SOLID](open for extension: adding a member is a field, not a newcasein every switch). The declaration isabstract class Enumeration<TEnumeration> where TEnumeration : Enumeration<TEnumeration>(Enumeration.cs:71-72). The type parameter is the concrete type itself, which is what letsAll,FromValueandFromNamebe per-enumeration and strongly typed:Priority.FromValue(2)returnsResult<Priority>with no type argument written by hand, and each closed type gets its own static lookup tables (static fields on a generic type are per-constructed-type). TheCA1000suppression (Enumeration.cs:67-70) exists for exactly this and states the reasoning: a non-generic sibling would return the base type and force a cast at every call site. - Concept introduced, lazy reflection frozen into a lookup.
[Rubric §12, Performance & Scalability]. ThreeLazy<T>statics (Enumeration.cs:74-82) build the member set once per closed type on first touch:MembersLazyrunsDiscoverMembers,ByValueLazyandByNameLazyproject it intoFrozenDictionaryinstances (the name dictionary usingStringComparer.OrdinalIgnoreCase).FrozenDictionaryis the right structure for a build-once, read-forever table: construction is more expensive, lookups are faster thanDictionary.ToFrozenDictionaryalso throwsArgumentExceptionon a duplicate key, which turns two members sharing aValueor aNameinto a fail-fast at first use rather than a silent shadowing bug (Enumeration.cs:30-35). - Walkthrough
protected Enumeration(int value, string name)(Enumeration.cs:87): the only constructor; derived types keep theirs private and expose members as static fields.Name(Enumeration.cs:95) andValue(Enumeration.cs:99): getter-only, tagged[DataMember(Order = 1)]and[DataMember(Order = 2)]under the class-level[DataContract](Enumeration.cs:65). The split is intentional and documented:Valueis the persisted representation,Nameis the serialized/display one.All(Enumeration.cs:105):IReadOnlyCollection<TEnumeration>, ordered byValue, cached for the lifetime of the closed type.FromValue(int value)(Enumeration.cs:115):TryGetValueon the frozen by-value map, elseError.Invariant(code: "Enumeration.UnknownValue", ...)naming the concrete type in the message (Enumeration.cs:120-124).FromName(string name)(Enumeration.cs:136): the case-insensitive twin, null-coalescing the argument tostring.Emptyfirst (Enumeration.cs:138), elseError.Invariant(code: "Enumeration.UnknownName", ...).ToString()(Enumeration.cs:149): returnsName.Equals(object?)(Enumeration.cs:152-155) andGetHashCode()(Enumeration.cs:158): type-guarded equality,GetType() == other.GetType() && Value == other.Value, hashed asHashCode.Combine(GetType(), Value). The class deliberately does not implementIEquatable<T>: the remark atEnumeration.cs:36-42cites Sonar S4035 (an unsealedIEquatable<T>breaks the equality contract for subclasses) and leaves that to a sealed derived type.[Rubric §15, Best Practices & Code Quality].DiscoverMembers()(Enumeration.cs:165-174): reflects overBindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly, keeps fields that areIsInitOnly(that is,readonly) and assignable toTEnumeration, reads their values, orders byValueand freezes to aReadOnlyCollection.DeclaredOnlyis the load-bearing flag: a derived hierarchy never inherits another type's members.
- Why it's built this way: the remarks (
Enumeration.cs:24-29) explain the placement and the one surprising choice. It lives inMMCA.Common.Sharedso it stays dependency-free and usable from Blazor WASM as well as Domain, and it deliberately does not derive fromValueObjectbecause theValueObjectsAreImmutableSealedInSharedfitness rule forces everyValueObjectderivative to be a sealed record, which would forbid the abstract static-member idiom this type exists for.RoleValue(group-08) is named in-source as the shipped precedent for the same trade-off.[Rubric §34, Architecture Governance]: the exception is documented at the point of deviation rather than hidden. - Where it's used: the framework ships the base plus its EF and JSON adapters;
EnumerationValueConverter<TEnumeration>andNullableEnumerationValueConverter<TEnumeration>map a member to itsintValueand back throughFromValue(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EnumerationValueConverter.cs:33,42,63), andAddAPIregisters the JSON factory (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:58). - Caveats / not-in-source: no concrete enumeration ships in ADC or Store today. The only derived
types in the workspace are the fixtures in
MMCA.Common.Shared.Tests/ValueObjects/EnumerationTests.csandEnumerationSerializationTests.cs, plusMMCA.Common.Infrastructure.Tests/Persistence/Conversions/EnumerationValueConverterTests.cs. Treat thePrioritysample in the doc comment (Enumeration.cs:50-62) as the usage template.
EnumerationConverter<TEnumeration>
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:224· Level 3 · class (private nested, sealed)
- What it is: the actual
JsonConverter<TEnumeration>for one closed enumeration type. It is a private nested class insideEnumerationJsonConverterFactory(Enumeration.cs:224-243), constrained the same way as the base:where TEnumeration : Enumeration<TEnumeration>(Enumeration.cs:225). - Depends on:
Enumeration<TEnumeration>(it callsEnumeration<TEnumeration>.FromName) andSystem.Text.Json. - Concept, the generic worker behind a converter factory.
[Rubric §2, Design Patterns](factory + strategy: the factory decides whether a type is convertible, the worker decides how). Being private is the point: nothing outside the factory can construct it directly, so the only way to obtain one is throughCreateConverter, which guarantees the generic argument is a legal closed enumeration. - Walkthrough
Read(Enumeration.cs:227): rejects a non-string token withthrow new JsonException($"{typeof(TEnumeration).Name} must be a string.")(Enumeration.cs:229-230), reads the string (Enumeration.cs:232), resolves it throughEnumeration<TEnumeration>.FromName(name)(Enumeration.cs:234) and throws a namingJsonExceptionwhen that fails (Enumeration.cs:235-236). Identical failure behaviour toCurrencyJsonConverter, which is deliberate.Write(Enumeration.cs:241-242):writer.WriteStringValue(value.Name). The wire shape is the member name, never the integer, so a JSON payload stays readable and a renumbering is not a breaking API change (the integer is the persistence representation, handled byEnumerationValueConverter<TEnumeration>).
- Where it's used: instantiated reflectively by
EnumerationJsonConverterFactory.CreateConverter(Enumeration.cs:202-204). It has no other caller and no public surface.
EnumerationJsonConverterFactory
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Enumeration.cs:195· Level 3 · class (sealed)
- What it is: a
JsonConverterFactorythat hands System.Text.Json aEnumerationConverter<TEnumeration>for any concrete smart enumeration, so every member serializes as itsName. - Depends on:
Enumeration<TEnumeration>(mutual: the base type carries[JsonConverter(typeof(EnumerationJsonConverterFactory))]atEnumeration.cs:66),EnumerationConverter<TEnumeration>, andSystem.Text.Json.Serialization. - Concept introduced, why an open generic needs a factory.
[Rubric §9, API & Contract Design]. AJsonConverter<T>is closed over oneT; there is no way to write one converter that servesPriority,Severityand every future enumeration.JsonConverterFactoryis the System.Text.Json extension point for exactly that:CanConvertanswers "is this type mine?" andCreateConverterbuilds the closed converter on demand. There is a second, subtler reason the factory has to exist at all, documented atEnumeration.cs:43-49and again atEnumeration.cs:184-188: System.Text.Json reads[JsonConverter]off the type it is converting without walking base types, so the attribute onEnumeration<T>does not reachPriority. A host therefore either repeats the attribute on each concrete type or registers this factory once.AddAPItakes the second route (MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:58, with the inline comment explaining theinherit: falsebehaviour). - Walkthrough
CanConvert(Type typeToConvert)(Enumeration.cs:198-199):GetEnumerationArgument(typeToConvert) == typeToConvert. Read that carefully: it is true only when the type is the type argument of its ownEnumeration<T>base, that is, only for the self-referencing closed type. A class deriving further from a concrete enumeration is left to the default converter rather than being silently serialized as its base (Enumeration.cs:206-212).CreateConverter(...)(Enumeration.cs:202-204):Activator.CreateInstance(typeof(EnumerationConverter<>).MakeGenericType(typeToConvert))cast toJsonConverter. Reflection runs once per type; System.Text.Json caches the resulting converter.GetEnumerationArgument(Type?)(Enumeration.cs:213-222): walkstype.BaseTypeupward looking for a generic type whose definition istypeof(Enumeration<>), returning its single generic argument, ornullat the top of the chain.
- Why it's built this way: registering one factory in
JsonSerializerOptions.Convertersgives uniform name-based JSON for every enumeration across the whole API surface, including the non-MVC paths (cache entries, outbox payloads, integration events, typedHttpClientcalls) that never see MVC model binding. TheHandleNulldefault offalseis left alone on purpose (Enumeration.cs:189-193) so nullable members still deserialize tonull. - Where it's used: registered in
AddAPI(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:58), named in that method's doc comment alongsideCurrencyJsonConverter(.../DependencyInjection.cs:30-31); also reachable via the[JsonConverter]attribute onEnumeration<TEnumeration>(Enumeration.cs:66) for a member typed as the base itself.
PhoneNumberInvariants
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Contact·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/PhoneNumberInvariants.cs:11· Level 3 · class (static, partial)
- What it is: the invariant checks for phone numbers: not empty, a length between 7 and 20
characters, and a character-class format check via
[GeneratedRegex]. - Depends on:
Error,Result. - Concept: the same
[GeneratedRegex]mechanism taught underEmailInvariants. The generated partial property isPhoneNumberRegexwith pattern^[\d\s\-\(\)\+]+$and the samematchTimeoutMilliseconds: 1000cap (PhoneNumberInvariants.cs:60-61), so it permits digits, whitespace, hyphens, parentheses and a plus sign and nothing else. - Walkthrough:
EnsurePhoneNumberIsValid(string phoneNumber, string source)(PhoneNumberInvariants.cs:26) guards in order:"PhoneNumber.Empty"on whitespace (PhoneNumberInvariants.cs:28-35), then it trims once into a local (PhoneNumberInvariants.cs:37) and runs both remaining checks against the trimmed value, so padding never counts toward the length and never fails the format check:"PhoneNumber.InvalidLength"outsideMinLength = 7..MaxLength = 20(PhoneNumberInvariants.cs:14,17,39-46) and"PhoneNumber.InvalidFormat"on a regex miss (PhoneNumberInvariants.cs:48-55). Like the email checks these areError.Invariant, and the length message is built withstring.Create(CultureInfo.InvariantCulture, ...). - Where it's used: called by
PhoneNumber.Create(PhoneNumber.cs:32);MinLengthandMaxLengthare the constants EF configurations and FluentValidation rules read.
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 rung of the entity ladder. It extends
BaseEntity<TIdentifierType>with soft delete, audit stamping, and optimistic concurrency, and it is the base every non-aggregate child entity derives from directly. - Depends on:
BaseEntity<TIdentifierType>(Level 1),IAuditableEntityandIRowVersioned(Level 0),ResultandError. TheUserIdentifierTypealias comes from the solution-wide global usings (see the primer). - Concept introduced, audit fields the domain reads but never writes.
[Rubric §3, Clean Architecture](assesses whether dependencies point inward and infrastructure concerns stay out of the domain): the clock and the current user both live in infrastructure, so this class exposes the audit fields withprivate setand lets EF Core write them throughentry.Property(...).CurrentValueduringSaveChangesAsync(AuditableBaseEntity.cs:8-10,AuditableBaseEntity.cs:22-23).[Rubric §9, API & Contract Design](assesses whether such concerns are centralized rather than repeated per handler): no command handler anywhere setsCreatedByorLastModifiedOn, becauseAuditSaveChangesInterceptordoes it for every trackedIAuditableEntity.
Soft delete is the other half.[Rubric §8, Data Architecture](assesses retention and deletion semantics): a delete flips a flag rather than removing a row, so foreign keys stay intact and history survives; EF global query filters then hide deleted rows from ordinary reads. ADR-005 (Website/docs-src/adr/005-soft-delete-vs-erasure.md) is where soft delete and true erasure are reconciled, andIAnonymizableis the erasure half of that pair. - Walkthrough
IsDeleted(AuditableBaseEntity.cs:20):virtual boolwith a private setter, the soft-delete flag itself.CreatedOn,CreatedBy,LastModifiedOn,LastModifiedBy(AuditableBaseEntity.cs:25,:27,:29,:31): the classic audit quartet, allvirtualwith private setters. The pair of#pragma warning disable S1144, CA1819/restorelines that bracket the block (AuditableBaseEntity.cs:24andAuditableBaseEntity.cs:54) is scoped to exactly these members and justified inline: the setters look unused because only EF calls them, andbyte[]is required for the rowversion mapping.DeletedOnandDeletedBy(AuditableBaseEntity.cs:39,AuditableBaseEntity.cs:45): nullable while the entity is active. Together they answer "when was this deleted, and by whom" without a separate audit-trail lookup (AuditableBaseEntity.cs:33-38).RowVersion(AuditableBaseEntity.cs:53):byte[]initialized to[], mapped as a SQL Serverrowversionvia[Timestamp]in the EF configurations. EF includes it in everyUPDATE/DELETEWHEREclause, which is what turns a lost update into aDbUpdateConcurrencyExceptioninstead of silent data loss (AuditableBaseEntity.cs:47-52).Delete()(AuditableBaseEntity.cs:67-80):public virtual Result. It guards onIsDeletedfirst and returnsError.AlreadyDeletedtagged with source and target (AuditableBaseEntity.cs:71-75), otherwise sets the flag and returns success. Note what it does not do: it never touchesDeletedOn/DeletedBy, because the interceptor derives those from the flag transition (AuditableBaseEntity.cs:59-64).Undelete()(AuditableBaseEntity.cs:89-104):protected, deliberately. Reversing a soft delete is a per-entity business decision (BR-135), so an entity opts in by implementingIReactivatable, typically aspublic Result Reactivate() => Undelete();(MMCA.Common/Source/Core/MMCA.Common.Domain/Interfaces/IReactivatable.cs:5-17). The method mirrorsDelete(): guard (Entity.NotDeleted), mutate, returnResult.
- Why it's built this way: splitting identity, audit, and aggregate behavior across three base
classes lets a child entity take audit and concurrency without inheriting a domain-event
collection it will never use. Returning
ResultfromDelete()/Undelete()rather than throwing keeps deletion on the same flow-control rail as everything else in the domain (see the Result pattern in the primer). Leaving the timestamps to the interceptor is what makes the stamps consistent: a singlenowand a single resolved user id per save, applied to every entry. - Where it's used: base of
AuditableAggregateRootEntity<TIdentifierType>and, directly, of the child entities inside aggregates (ADC'sRoom,EventSpeaker,CategoryItem,SessionSpeaker; Store'sOrderLine,ShoppingCartItem). The stamping side is implemented byAuditSaveChangesInterceptor(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/AuditSaveChangesInterceptor.cs:104-105) and covered byAuditableBaseEntityTestsandAuditableBaseEntityAdditionalTests. - Caveats / not-in-source: the delete stamps are written only on a transition of the flag
(
AuditSaveChangesInterceptor.cs:98-102), so updating an already-deleted row keeps the stamps of the delete that produced it rather than refreshing them.
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Contact·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/Email.cs:16· Level 4 · record (sealed)
- What it is: a validated, normalized value object for an email address. Every construction path
goes through
Create, which rejects invalid formats and lowercases the result; the private constructor exists for JSON round-tripping only. - Depends on:
ValueObject(Level 0),EmailInvariants(Level 3),Result<T>(Level 2). - Concept introduced, normalization at construction plus implicit conversion.
[Rubric §4, Domain-Driven Design](rich value objects with invariant-protected construction). Three ideas combine here: (1) the[JsonConstructor]-tagged private constructor (Email.cs:22-23) keeps ad-hoc construction out while letting System.Text.Json rehydrate; (2)Createvalidates and normalizes, returningResult<Email>instead of throwing; (3)public static implicit operator string(Email email)(Email.cs:45) lets anEmaildrop into astringposition without a cast, a pragmatic bridge for code that has not adopted the value object yet. The#pragma warning disable CA1308aroundToLowerInvariant(Email.cs:38-40) is a scoped suppression with its justification on the same line ("Email addresses are conventionally lowercase per RFC 5321").[Rubric §15, Best Practices & Code Quality](suppressions are narrow and explained, never blanket). - Walkthrough
Value(Email.cs:20): getter-onlystring, the normalized address, tagged[DataMember(Order = 1)]under the class-level[DataContract](Email.cs:15).Create(string value)(Email.cs:30): trims null-safely withvalue?.Trim() ?? string.Empty(Email.cs:32), callsEmailInvariants.EnsureEmailIsValid(trimmed, nameof(Create))(Email.cs:34), propagatesresult.Errorson failure (Email.cs:36), and only then lowercases (Email.cs:39). Order matters: validation runs on the trimmed input, normalization on the validated value.implicit operator string(Email.cs:45) andToString()(Email.cs:48): both returnValue.
- Why it's built this way: normalizing once at construction means the rest of the system can
compare, index and store emails case-insensitively without a
.ToLower()at every use. The remarks (Email.cs:7-14) also pin the persistence shape: EF maps this withHasConversion, notOwnsOne, so the column stays a flatnvarchar, and the framework ships the converter pair rather than asking each configuration to hand-roll the lambdas:EmailValueConverter(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EmailValueConverter.cs:33) andNullableEmailValueConverter(.../EmailValueConverter.cs:60) for an optionalEmail?. - Where it's used: the Store Identity
Customeraggregate holdspublic Email Email(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Domain/Customers/Customer.cs:36) and builds it throughEmail.Createin bothCreate(Customer.cs:77) andChangeEmail(Customer.cs:153); its EF configuration applies.HasConversion(new EmailValueConverter())(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Infrastructure/Persistence/EntityConfiguration/CustomerConfiguration.cs:36). Note the layering:RegisterRequeststill carries a rawstring Email(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RegisterRequest.cs), and the conversion into the value object happens inside the domain factory.[Rubric §9, API & Contract Design].
PhoneNumber
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Contact·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Contact/PhoneNumber.cs:16· Level 4 · record (sealed)
- What it is: a validated, trimmed value object for a phone number, structurally parallel to
Email. - Depends on:
ValueObject(Level 0),PhoneNumberInvariants(Level 3),Result<T>(Level 2). - Concept: the same private-constructor + static-factory + implicit-conversion shape taught under
Email; this section cross-references rather than repeating it. One difference worth noting: there is no case normalization (a phone number has no case), andCreatevalidates the raw string then storesvalue.Trim()(PhoneNumber.cs:32,36), whereasEmail.Createtrims first and validates the trimmed value.PhoneNumberInvariants.EnsurePhoneNumberIsValidtrims internally before its length and format checks (PhoneNumberInvariants.cs:37), so the two orderings agree in practice. - Walkthrough:
Value(PhoneNumber.cs:20, getter-only,[DataMember(Order = 1)]under[DataContract]atPhoneNumber.cs:15); the[JsonConstructor]private constructor (PhoneNumber.cs:22-23);Create(string value)(PhoneNumber.cs:30) delegating toPhoneNumberInvariants.EnsurePhoneNumberIsValidand returningResult.Failure<PhoneNumber>with the propagated errors (PhoneNumber.cs:34); the implicitoperator string(PhoneNumber.cs:41) andToString()(PhoneNumber.cs:44). - Why it's built this way: as with
Email, the remarks (PhoneNumber.cs:7-14) specifyHasConversionrather thanOwnsOneso the column staysnvarchar, and point at the shippedPhoneNumberValueConverter(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/PhoneNumberValueConverter.cs:33) and itsNullablePhoneNumberValueConvertersibling (.../PhoneNumberValueConverter.cs:61). - Where it's used: no ADC or Store entity holds a
PhoneNumbertoday; the type and its EF converters ship for adopters, and behaviour is pinned byMMCA.Common.Shared.Tests/ValueObjects/PhoneNumberTests.cs.
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. It adds a domain-event queue and a set of
protected child-collection helpers on top of
AuditableBaseEntity<TIdentifierType>, and implementsIAggregateRoot. - Depends on:
AuditableBaseEntity<TIdentifierType>(Level 3),IAggregateRoot(Level 1),IAuditableEntityandIReactivatable,IDomainEvent,ResultandError. - Concept introduced, the aggregate as a consistency boundary.
[Rubric §4, Domain-Driven Design](assesses whether aggregates own their children and are the only external entry point for change): children are held in privateList<T>fields, exposed read-only, and mutated only through methods on the root. The helpers on this class are the mechanics of that rule, so the aggregate method above them is left to express the meaning.[Rubric §6, CQRS & Event-Driven](assesses where events originate and when they are dispatched): the root accumulatesIDomainEventinstances during the business operation and infrastructure dispatches them after a successful save (AuditableAggregateRootEntity.cs:6-11), which is the entry point ADR-003 (Website/docs-src/adr/003-outbox-dual-dispatch.md) builds the outbox on. - Walkthrough
private readonly List<IDomainEvent> _domainEvents = [](AuditableAggregateRootEntity.cs:16) withIReadOnlyCollection<IDomainEvent> DomainEvents(:18): the accumulator, drainable by infrastructure but not appendable from outside.AddDomainEvent(IDomainEvent)(AuditableAggregateRootEntity.cs:24-28): null-guards, then appends. Called from the aggregate's own factory and mutation methods.ClearDomainEvents()(AuditableAggregateRootEntity.cs:34): empties the queue wholesale.RemoveDomainEvents(IEnumerable<IDomainEvent>)(AuditableAggregateRootEntity.cs:37-50): the surgical version. It builds aHashSet<IDomainEvent>overReferenceEqualityComparer.Instance(:43) and removes only the captured instances, because two structurally equal events raised separately are still two distinct occurrences (:41-42). An empty input returns early (:44-47).SetItems<TChildEntity>(List<TChildEntity>, IEnumerable<TChildEntity>)(AuditableAggregateRootEntity.cs:60-74): materializes the incoming sequence once to avoid double enumeration (:69), calls the validation hook, thenClear()+AddRange()on the same list instance (:72-73). Never replacing the list reference is what keeps EF change tracking able to see the adds and removes.ValidateSetItems<TChildEntity>(AuditableAggregateRootEntity.cs:85-90):protected virtual, empty by default. The extension point for rules such as "a fulfilled order line cannot be removed".GetChildOrNotFound<TChild, TChildId>(AuditableAggregateRootEntity.cs:103-120): aFirstOrDefaultover the in-memory collection matching onId.Equals(childId) && !c.IsDeleted(:110), returningError.NotFoundwith source and target rather than throwing or returningnull.RemoveChildOrNotFound<TChild, TChildId>(AuditableAggregateRootEntity.cs:156-178): the lookup above followed by the child's ownDelete(), short-circuiting on either failure. The deleted child comes back in the result rather than being consumed here, because which domain event a removal raises is aggregate vocabulary and therefore the caller's decision (:129-145shows the intended call shape).RestoreChild<TChild, TChildId>(AuditableAggregateRootEntity.cs:212-249): constrainedwhere TChild : AuditableBaseEntity<TChildId>, IReactivatable(:217). It takes the child as an instance, not an id, because a soft-deleted row is hidden by the global query filter and is not reachable through the loaded collection: the caller resolves it with anignoreQueryFiltersread (:183-189). It rejects a candidate that is not soft-deleted using an error code the caller supplies (:225-231), callsReactivate(), and re-adds the child only when the collection does not already carry that id (:243-246).DeleteChildren<TChild, TChildId>(AuditableAggregateRootEntity.cs:273-292): cascades a soft delete across a child collection, skipping children that are already deleted (:283-286) so re-deleting a parent is idempotent, and combining the rest withResult.Combine(:291). The results list is allocated lazily (:279,:288), so a childless cascade allocates nothing.
- Why it's built this way: every one of these helpers replaces a loop that each aggregate used
to hand-roll. The consistent split is that the base class owns the mechanics (find, delete,
restore, cascade, aggregate the errors) while the aggregate method owns the vocabulary (which
event, which error code), which is why
RemoveChildOrNotFoundandRestoreChildhand the child back instead of raising an event themselves, and whyRestoreChildtakesnotDeletedErrorCodeas a parameter for the same reasonGetChildOrNotFoundtakessource(AuditableAggregateRootEntity.cs:201-206). Ownership checks and field re-validation stay in the calling method and run before the helper, so a rejected restore leaves the child untouched (:190-195). - Where it's used: base class for every aggregate root in the consumers. ADC's
Eventuses threeDeleteChildrencalls in oneResult.Combine(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:361-363),RestoreChildfor room reinstatement (Event.cs:496) andRemoveChildOrNotFoundfor room removal (Event.cs:486);Sessioncascades to its speakers and question answers (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:288-289) andCategoryto its items (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:107). The event queue is drained byDomainEventSaveChangesInterceptor, which callsRemoveDomainEventsper captured entry (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:364). Covered byAuditableAggregateRootEntityTestsandAuditableAggregateRootEntityAdditionalTests. - Caveats / not-in-source:
SetItemsand the child helpers operate purely on the in-memory collection. If an aggregate was loaded without its children included,GetChildOrNotFoundreturnsNotFoundfor a child that exists in the database; nothing in this class detects that case.
Currency
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Financial·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Financial/Currency.cs:14· Level 3 · record (sealed)
- What it is: an ISO 4217 currency value object built as a closed set:
Currency.UsdandCurrency.Eurare the only public instances (Currency.cs:26,29), the constructor is private (Currency.cs:31), andNoneis aninternalsentinel used byMoney(Currency.cs:23). - Depends on:
CurrencyJsonConverter(mutual cycle),Error,Result,ValueObject. - Documented cycle,
CurrencyandCurrencyJsonConverter. Both live in the same file (Currency.cs:14andCurrency.cs:73).Currencycarries[JsonConverter(typeof(CurrencyJsonConverter))](Currency.cs:13) while the converter'sReadcallsCurrency.FromCode(Currency.cs:83). The mutual reference puts both at Level 3. - Concept introduced, the closed-set (type-safe enum) value object.
[Rubric §4, DDD](eliminates primitive obsession: no raw"USD"strings travelling through the domain). Instead of astring, all code holdsCurrency.Usd, a statically typed singleton. Validation happens once, at the boundary, inFromCode; inside the domain you are guaranteed to hold a known currency. Adding a currency means adding a field and listing it inAll(Currency.cs:54-58), which is the single extension point. - Walkthrough
EmptyCurrency/InvalidCurrency(Currency.cs:17,20): pre-builtError.Validationsingletons for the two failure paths, soFromCodeallocates nothing on a bad call.None(Currency.cs:23):internal static readonly Currency None = new(string.Empty), the empty-code sentinel; the doc comment (Currency.cs:10-11) is explicit that it is never exposed to API consumers.Code(Currency.cs:34):stringwith aninitaccessor, the ISO three-letter code.FromCode(string code)(Currency.cs:41): empty-guard first (Currency.cs:43-44), then a case-insensitiveFirstOrDefaultscan overAll(Currency.cs:46), returning the singleton on success, soCurrency.Usdis reference-identical everywhere.All(Currency.cs:54):IReadOnlyCollection<Currency>collection expression containingUsdandEur.
- Why it's built this way: pre-constructed singletons remove per-call allocation and make
comparison trivial; the closed set makes an unknown code representable only outside the domain.
[Rubric §12, Performance & Scalability]for the allocation-free path. - Where it's used:
Moneyholds aCurrency(Money.cs:35);CurrencyJsonConverterserializes it; the API layer registers its ownCurrencyJsonConverterinto MVC'sJsonSerializerOptionsinAddAPI(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:53). - Caveats / not-in-source:
Allhas exactly two entries today. A deployment needing a third currency changes framework source, not configuration.
DateRange
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Time·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Time/DateRange.cs:9· Level 3 · record (sealed)
- What it is: an immutable value object for a date-only range (
DateOnly Start,DateOnly End,DateRange.cs:12,15), inclusive on both ends, with the single invariant thatEndis not beforeStart. - Depends on:
Error,Result,ValueObject, and BCLDateOnly. - Concept reinforced, the lightweight single-invariant factory.
[Rubric §4, DDD](a temporal range is a domain concept, not a loose pair of dates). Compare withAddress:Address.Createdelegates to a separate invariant class because six constants and multiple error codes justify one;DateRange.Create(DateRange.cs:30-35) expresses its one rule inline as a conditional expression. Calibrating the ceremony to the number of rules is the convention here. - Walkthrough
Create(DateOnly start, DateOnly end)(DateRange.cs:30): returnsError.Validation("DateRange.Invalid", ...)whenend < start, otherwiseResult.Success(new DateRange(start, end))through the private constructor (DateRange.cs:17). It usesError.Validation, notError.Invariant, because the inputs are raw caller-supplied dates: a validation problem, not corrupted internal state.LengthInDays(DateRange.cs:38):End.DayNumber - Start.DayNumber, avoidingTimeSpanarithmetic onDateOnly.Overlaps(DateRange other)(DateRange.cs:46):ArgumentNullException.ThrowIfNull(other)then the standard half-open formulaStart < other.End && End > other.Start(DateRange.cs:48-49).Contains(DateOnly instant)(DateRange.cs:55): inclusive on both ends,instant >= Start && instant <= End.Deconstruct(DateRange.cs:61): enablesvar (start, end) = dateRange.
- Why it's built this way:
DateOnlyrather thanDateTimesignals that the concept carries no time-of-day and no time zone; wrapping the pair in a type makes swappingstartandendat a call site impossible. - Where it's used: no production entity in ADC or Store holds a
DateRangetoday; it is a shipped framework primitive covered byMMCA.Common.Shared.Tests/ValueObjects/DateRangeTests.cs. The ADC ConferenceEventenforces the same rule over two looseDateOnlyparameters instead, viaEventInvariants.EnsureDateRangeIsValid(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:114, called fromEvent.cs:179andEvent.cs:252), with the request-side counterpartEventDateRangeRules<T>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:91). That is the honest state of the code: the primitive exists, the app has not adopted it.
DateTimeRange
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Time·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Time/DateTimeRange.cs:10· Level 3 · record (sealed)
- What it is: the
DateTime-precision sibling ofDateRange, carryingDateTime StartandDateTime End(DateTimeRange.cs:13,16). - Depends on:
Error,Result,ValueObject. - Concept: identical to
DateRange; this section cross-references rather than repeating. The only behavioural difference is the derived member:Duration(DateTimeRange.cs:39) returnsEnd - Startas aTimeSpan, whereDateRangereturns an integer day count. - Walkthrough:
Create(DateTime start, DateTime end)(DateTimeRange.cs:31) uses the same conditional-expression pattern withError.Validation("DateTimeRange.Invalid", ...);Overlaps(DateTimeRange.cs:46),Contains(DateTimeRange.cs:55) andDeconstruct(DateTimeRange.cs:61) mirrorDateRangeline for line. - Where it's used: as with
DateRange, no ADC or Store entity holds one today; coverage isMMCA.Common.Shared.Tests/ValueObjects/DateTimeRangeTests.cs. - Caveats / not-in-source:
StartandEndare plainDateTime, so the type carries no time zone orDateTimeKindguarantee. Nothing in the source normalizes them to UTC; a caller mixing kinds would get arithmetic that compiles and lies.
CurrencyJsonConverter
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Financial·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Financial/Currency.cs:73· Level 3 · class (sealed)
- What it is: a
JsonConverter<Currency>that writesCurrencyas its ISO code string and reads it back throughCurrency.FromCode. - Depends on:
Currency(mutual cycle, see above) andSystem.Text.Json. - Concept introduced, the strict boundary converter.
[Rubric §9, API & Contract Design](assesses consistent, stable serialization) and[Rubric §11, Security](assesses input rejected at the edge rather than coerced). The class doc (Currency.cs:61-72) states the design goal in one sentence: a non-string token and an unknown code both throw, "matching the API-layer converter so non-MVC paths (cache, outbox, integration events, typed HttpClient calls) fail the same way MVC model binding does". That matters because aCurrencycrosses far more than the controller boundary: it is also serialized into outbox rows and cache entries. - Walkthrough
Read(Currency.cs:76): rejects any token that is not a JSON string withthrow new JsonException("Currency must be a string.")(Currency.cs:78-79), so{"currency": 5}fails loudly instead of coercing; thenCurrency.FromCode(code)(Currency.cs:83) and a secondJsonExceptionnaming the offending code on failure (Currency.cs:84-85). Throwing (rather than returning aResult) is correct here: malformed JSON is a deserialization error, not a domain outcome.Write(Currency.cs:91-92):writer.WriteStringValue(value.Code). The wire shape is just the three-letter string.- Null handling:
HandleNullis left at its defaultfalse(documented atCurrency.cs:68-71), so System.Text.Json short-circuits a JSONnullbefore this converter runs and aCurrency?orMoney?member still deserializes tonullrather than throwing.
- Where it's used: applied automatically through the
[JsonConverter]attribute onCurrency(Currency.cs:13); the separate API-layerCurrencyJsonConverteris registered globally inAddAPI(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:53). - Caveats / not-in-source: there is no version sentinel. If the closed code set ever shrinks,
deserializing a previously stored code (a stale cache entry, an old outbox row) throws
JsonException.
Money
MMCA.Common.Shared ·
MMCA.Common.Shared.ValueObjects.Financial·MMCA.Common/Source/Core/MMCA.Common.Shared/ValueObjects/Financial/Money.cs:21· Level 4 · record (sealed)
- What it is: a value object pairing a
decimal Amountwith aCurrency(Money.cs:31,35), carrying arithmetic operators, aResult-safeAdd, andCurrency.Noneas a zero-accumulator sentinel. - Depends on:
ValueObject(Level 0),Currency(Level 3),Error(Level 1),Result<T>(Level 2). - Concept introduced, behaviour on the value object (and a deliberate two-path API).
[Rubric §4, DDD](a rich value object encapsulates behaviour, not just data).Moneyis the most concept-dense type in this group:- Errors as named constants.
NoCurrencyandCurrencyMismatch(Money.cs:24,27) arepublic static readonly Errorfields, so a caller can compare against the constant instead of string-matching a message. - Two addition paths.
operator +(Money.cs:84) delegates toAddand throwsInvalidOperationExceptioncarryingresult.Errors[0].Messagewhen the currencies clash (Money.cs:86-89); the doc comment above it (Money.cs:77-80) tells you to "preferAddfor Result-based error handling". This is an intentional usability trade-off: operator syntax for trusted arithmetic inside one currency, theResultpath for anything derived from untrusted input. Currency.Noneas an additive identity.AddUnchecked(Money.cs:131-138) returns the other operand untouched when either side has no currency, which is what makesZero()usable as anAggregateseed before the target currency is known.- Fail-fast round-trip constructor. The
[JsonConstructor]private constructor (Money.cs:51-58) callsArgumentNullException.ThrowIfNull(currency), and its doc comment (Money.cs:40-47) explains the contract: "no currency" isCurrency.None, nevernull, so a materializer that yieldsnullis a broken contract and is surfaced here rather than as a NullReferenceException three layers away.[Rubric §15, Best Practices & Code Quality]. - A narrow test back-door.
internal static Money CreateUnsafe(decimal, Currency)(Money.cs:153) is exposed to test assemblies throughInternalsVisibleTo, giving tests a way to build otherwise-illegal values without opening a public hole.
- Errors as named constants.
- Walkthrough
Amount(Money.cs:31) andCurrency(Money.cs:35):init-only,[DataMember(Order = 1)]and[DataMember(Order = 2)]under[DataContract](Money.cs:20).IsNegative(Money.cs:38) is a computed predicate; negative amounts are explicitly allowed (refunds, adjustments).Create(decimal amount, Currency currency)(Money.cs:67): null-guards the reference, then rejectsCurrency.NonewithNoCurrency(Money.cs:71-72), because external callers must name a real currency.operator +(Money.cs:84) andoperator *(Money.cs:96, by anintquantity), withMultiply(Money, int)(Money.cs:124) as the named alias the operator-averse analyzers expect.Add(Money first, Money second)(Money.cs:107): returnsCurrencyMismatchenriched with.WithSource(nameof(Add))and.WithTarget($"{first.Currency.Code} + {second.Currency.Code}")(Money.cs:112-114) only when both sides carry a real and differing currency; otherwise it delegates toAddUnchecked.Zero()(Money.cs:142) returnsnew(0, Currency.None);Zero(Currency)(Money.cs:147) fixes the currency.IsZero()(Money.cs:157) isthis == Zero(Currency), which works precisely because record equality is structural.
- Why it's built this way: putting arithmetic on the type removes scattered
a.Amount + b.Amountexpressions that quietly ignore currency. Persistence follows the same "ship the helper" rule asEmail: the remarks (Money.cs:14-19) direct configurations atEntityTypeBuilderExtensions.OwnsMoney(group-07,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeBuilderExtensions.cs:51), which produces the amount column plus ISO-code column mapping together with the currency round-trip fallback every hand-rolledOwnsOneblock would otherwise have to repeat. - Where it's used: the Store Sales
Orderaggregate holdspublic Money Total(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Domain/Orders/Order.cs:37), seeds it withMoney.Zero()(Order.cs:90), takesMoney UnitPriceon its line-item tuple (Order.cs:105) and accumulates withMoney.Add(order.Total, unitPrice * quantity)(Order.cs:122), the exact combination of theNoneidentity, the*operator and theResult-safeAdddescribed above. The Catalog module carries prices the same way, and the UI formats values throughMoneyExtensions.
CommonInvariants
MMCA.Common.Domain ·
MMCA.Common.Domain.Invariants·MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:13· Level 5 · class (static)
What it is: the shared catalogue of reusable domain invariant checks, 22 static methods that every module's own invariant class delegates to. Each one returns
Result, so a check either passes withResult.Success()or yields a typed invariant failure. Nothing here throws.Depends on:
ResultandError(via theError.Invariant(...)factory),Moneyfor the monetary check, andSupportedCulturesfor the culture check. Everything else is BCL:string,Enum,TimeZoneInfo,Uri,HashSet<T>.Concept introduced, the invariant-helper library.
[Rubric §4, Domain-Driven Design](assesses whether business rules live in the domain and are expressed in its language): the helpers standardize the shape of an invariant failure (code,message,source,target) while leaving the vocabulary to the caller, which is why every method takes those four strings and invents none of them.[Rubric §15, Best Practices & Code Quality](assesses whether a repeated rule has one source of truth): without this class, every module would re-codestring.IsNullOrWhiteSpacewith slightly different codes and messages; with it, tightening a bound is a one-line change in one file.[Rubric §1, SOLID]: one place owns each kind of check, the DRY corollary of single responsibility.[Rubric §11, Security](assesses whether untrusted input is validated at the boundary it enters):EnsureUrlIsWellFormedis a security check, not a formatting one. A bounded-string check alone letsjavascript:anddata:values through, and those reach the browser as executable content the moment a link or an image renders them (CommonInvariants.cs:276-282). Requiring an absolute URI on anhttp/httpsscheme closes that without constraining host or path.Walkthrough: two constants,
LightTheme = "light"(CommonInvariants.cs:16) andDarkTheme = "dark"(:19), then the catalogue. Every method isstatic, most are expression-bodied ternaries, and all end with the samecode, message, source, targetquartet.Method File:Line What it enforces (and what passes) EnsureStringIsNotEmptyCommonInvariants.cs:30Fails on null, empty, or whitespace. EnsureStringMaxLengthCommonInvariants.cs:47Fails only when a non-null value exceeds maxLength; null and empty pass.EnsureIdIsNotDefault<TId>CommonInvariants.cs:63where TId : struct, IEquatable<TId>; fails ondefault(0 forint,Guid.Empty).EnsureBytesAreNotEmptyCommonInvariants.cs:79Fails on a null or zero-length byte[].EnsureIntIsPositiveCommonInvariants.cs:94Fails on value <= 0.EnsureMoneyIsNotNegativeCommonInvariants.cs:110Fails on null or Money.IsNegative; zero passes (free items).EnsureCollectionIsNotEmpty<T>CommonInvariants.cs:126Fails on null or Count == 0.EnsurePreferredCultureIsValidCommonInvariants.cs:142Null passes (follow the request default); otherwise must be in SupportedCultures(ADR-027).EnsurePreferredThemeIsValidCommonInvariants.cs:158Null passes (follow the system); otherwise light/dark,OrdinalIgnoreCase(ADR-028).EnsureEnumIsDefined<TEnum>CommonInvariants.cs:177Enum.IsDefined, rejecting the arbitrary integers a cast or a deserialized payload can produce.EnsureEndIsNotBeforeStart<T>CommonInvariants.cs:197where T : IComparable<T>; equal endpoints pass, so a single-day range is allowed.EnsureStringLengthIsWithinCommonInvariants.cs:219Required string within an inclusive [min, max]; null/empty/whitespace fails. One error instead of two.EnsureOptionalStringMaxLengthCommonInvariants.cs:242Same bound as EnsureStringMaxLength; exists so an optional field states that intent at the call site (:229-234).EnsureTimeZoneIsValidCommonInvariants.cs:266Null passes; otherwise TimeZoneInfo.TryFindSystemTimeZoneById, which keeps the check off the exception path and also rejects a corrupt entry (:252-259).EnsureUrlIsWellFormedCommonInvariants.cs:293Null/empty passes; otherwise absolute http/httpsonly. Carries a justified CA1054 suppression (:289-292).EnsureCountIsWithinCommonInvariants.cs:310Inclusive [minCount, maxCount]range on a count.EnsureCollectionIsEmpty<T>CommonInvariants.cs:327The mirror of EnsureCollectionIsNotEmpty: the guard a delete needs when dependants must go first. Null passes.EnsureValuesAreUnique<T>CommonInvariants.cs:348No duplicates under a caller-supplied IEqualityComparer<T>(null = the type default). A null sequence passes, vacuously unique.EnsureFlagIsTrueCommonInvariants.cs:378The state guard for an action a flag requires (an event must be published). EnsureFlagIsFalseCommonInvariants.cs:394The state guard for an action a flag forbids (a service session cannot be edited). EnsureNullableIntIsPositiveCommonInvariants.cs:410Null passes; zero and negatives fail. EnsureIntIsNotNegativeCommonInvariants.cs:426Zero passes, which is what separates it from EnsureIntIsPositive(an on-hand quantity may be zero).The one private member,
IsAbsoluteHttpUrl(CommonInvariants.cs:436-439), backsEnsureUrlIsWellFormed:Uri.TryCreate(url, UriKind.Absolute, out var uri)plus an ordinal scheme comparison againstUri.UriSchemeHttp/Uri.UriSchemeHttps.Why it's built this way: the class lives in
MMCA.Common.Domainrather thanMMCA.Common.Sharedso it sits below the Application layer while staying reachable from every module's domain invariant class. ReturningResultrather than throwing is the codebase-wide flow-control convention, and it is what lets a factory compose many checks withResult.Combineand report every violation at once instead of the first.Error.Invarianttags each failure withErrorType.Invariant, which the API layer later maps to an HTTP status. Note the deliberate non-overlap policy visible in the doc comments: where two helpers could have been merged (length plus URL scheme, time zone plus emptiness), the comments tell the caller to compose instead (CommonInvariants.cs:257-258,:280-281), keeping each error attributable to one rule.Where it's used: the value-object invariant classes in this group,
AddressInvariants,EmailInvariants,PhoneNumberInvariants, andPushNotificationInvariants; then the module invariant classes in every consumer. In MMCA.ADC alone there are 78 call sites across 18 files, for exampleEventInvariants(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:73-74,:96,:112,:151),SessionInvariants,SpeakerInvariants,UserSessionBookmarkInvariants, andUserInvariants. MMCA.Store usesEnsureMoneyIsNotNegativeinMMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Domain/Orders/OrderInvariants.cs:28andMMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Domain/Products/ProductInvariants.cs:76. Exercised directly byCommonInvariantsTests.Caveats / not-in-source:
EnsureTimeZoneIsValidresolves against the host's time zone database (CommonInvariants.cs:268), so an identifier valid on a Windows developer machine and an identifier valid on a Linux CI runner are not guaranteed to be the same set. Nothing in this file normalizes between the two naming schemes.
⬅ Result & Error Handling • Index • Querying: Specifications, Filtering & the Entity Query Service ➡