Onboarding guide
17. ADC Conference - Domain Model & Module Contracts
What this chapter covers. This is the heart of the Atlanta Developers Conference application, the
Conference bounded context, the largest and richest domain in MMCA.ADC. It models everything an
organizer curates and an attendee browses: the Event (the conference itself, with its rooms,
speaker roster, and venue details), the Session (a talk on the schedule), the Speaker, the
Sponsor (the sold sponsorship and expo-booth record), the Activity (the party, coffee connect,
or closing ceremony that is deliberately not a session), the Category/CategoryItem taxonomy
(tracks, levels, session formats), and the Question/answer machinery that captures structured
metadata about events, sessions, and speakers. Eight aggregate roots (one of them an AI scorecard),
nine child entities, the static invariant classes that guard their business rules, the sixteen
domain events every mutation raises, a pure domain service that coordinates the
cross-aggregate cascade delete, and, in the module's MMCA.ADC.Conference.Shared project, the DTO
contracts, the cross-module service interfaces the Engagement module calls, the integration
events that keep the User-to-Speaker link and the engagement points ledger consistent across
services, and the decision-support read models that power the organizer's session-selection
dashboard. The detailed per-type sections follow; this overview shows how the pieces fit and how a
single change flows through them.
This chapter is almost entirely an instantiation of the framework taught in groups 1 through 14,
applied to a real, non-trivial domain. If a pattern here looks unfamiliar, it was introduced upstream
and is only cross-referenced now: the Result pattern
(G01), the
AuditableAggregateRootEntity<TIdentifierType>
entity hierarchy, the IdValueGeneratedAttribute
marker, the IAuditedEntity change-history opt-in,
the IReactivatable restore marker, and
DomainEntityState (G02), the
BaseDomainEvent /
EntityChangedEvent<TIdentifierType>
event bases and the outbox spine (G04), the
INavigationPopulator<in TEntity>
cross-container eager-loading extension point (G11), and the
IModule composition system (G14). The lens this
chapter most strongly embodies is [Rubric §4, Domain-Driven Design] (does the model mirror the
business: aggregates, value objects, invariants, ubiquitous language?): this is the codebase's most
complete DDD specimen, and it is worth reading slowly, because the same shapes repeat across every
aggregate.
Two packages, one bounded context
The Conference context spans two of the module's projects, and the split is deliberate Clean
Architecture ([Rubric §3, Clean Architecture]). MMCA.ADC.Conference.Domain holds the
behavior-rich aggregates, their invariants, their domain events, and the domain service: the ring
that knows nothing about EF Core, ASP.NET, or serialization.
MMCA.ADC.Conference.Shared holds the contracts that cross boundaries: the DTOs returned by
the API, the cross-module validation interfaces the Engagement module consumes
(ISessionBookmarkValidationService and
IEventLiveValidationService), the
SpeakerLinkedToUser/SpeakerUnlinkedFromUser
and SessionFeedbackSubmitted/EventFeedbackSubmitted
integration events other modules subscribe to, and the feature-flag, permission, and status constants.
Read the reference direction carefully, because it is the opposite of what the names suggest: Shared
is the module's bottom layer and Domain references it, not the other way round
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:8-12).
The project file records why: the Event aggregate exposes the
QuestionModerationDefault enum, and that enum lives in Shared so the
DTOs, the Blazor UI, and the Engagement module can use it without referencing Domain at all. It is
the same direction MMCA.Common.Domain takes toward MMCA.Common.Shared (see
primer §1). Shared itself depends only on the two innermost
framework packages, MMCA.Common.Shared and MMCA.Common.Domain
(.../MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.Shared.csproj:7-10), which is what lets four
other projects reference it: the module's own UI
(.../MMCA.ADC.Conference.UI/MMCA.ADC.Conference.UI.csproj:15), Engagement's application and UI
layers, Identity's application layer, and even the standalone Engagement service host, which pulls it
in purely to register the disabled Conference stubs
(MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/MMCA.ADC.Engagement.Service.csproj:26-30). A
handful of Shared types are internal (the two disabled stubs), so the project grants
InternalsVisibleTo to Conference.API, which registers them, and to its own test assembly
(MMCA.ADC.Conference.Shared.csproj:2-6).
The AssemblyReference/ClassReference pair in Domain
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/AssemblyReference.cs:5,11) is the
conventional per-project anchor every layer in this repo ships: a static holder for the compiled
Assembly and its simple name (AssemblyReference.cs:7-8) that reflection-based registration can
name without hard-coding a string. Note for accuracy that the architecture-fitness map does not use
it: it pins the Conference domain assembly through a real type instead,
typeof(Conference.Domain.Events.Event).Assembly
(MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/AdcArchitectureMap.cs:36).
Eight aggregates and their ownership boundaries
An aggregate is a root entity plus the children it exclusively owns; invariants are enforced
inside the boundary, and references across aggregates are by ID, never by object graph. Every root
here derives from
AuditableAggregateRootEntity<TIdentifierType>,
so it inherits soft-delete, audit stamping, and the buffered DomainEvents collection:
Event(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:24) ownsRoom(the physical rooms),EventSpeaker(the speaker roster, a join toSpeakerby ID), andEventQuestionAnswer(event-level structured answers). ItsIdis database-generated (marked[IdValueGenerated],Event.cs:23), and it also carries the per-event live-layer moderation default (Event.cs:80), the published flag (Event.cs:74), the organizer contact email, sponsorship packet URL, and ticketing URL that drive the public pages (Event.cs:59,65,71, each of which the pages hide entirely when absent), and the Sessionize refresh stamp written byRecordSessionizeRefresh(Event.cs:83,86,343).Session(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:22) ownsSessionSpeaker,SessionCategoryItem, andSessionQuestionAnswer. Critically, a Session references itsEventandRoomby scalar FK (EventIdatSession.cs:64,RoomIdatSession.cs:67): they are separate aggregates, even though the model exposesEvent/Roomnavigations (Session.cs:69-75, both[Navigation]-decorated and both private-setter, so the populator and query filtering can hydrate them) used only for read-side filtering, never to reach across the boundary and mutate. SessionIds are Sessionize-assigned, not database-generated (Session.cs:15), andDurationis a computed property overStartsAt/EndsAtrather than a stored column (Session.cs:80-82).Speaker(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:22) ownsSpeakerCategoryItemandSpeakerQuestionAnswer, holds an optionalEmailvalue object (Speaker.cs:31), and carries the cross-moduleLinkedUserIdFK to an IdentityUser(Speaker.cs:58). SpeakerIds are Sessionize-assigned GUIDs, with a fallback toGuid.NewGuid()for organizer-created and seeded speakers (see the in-code note atSpeaker.cs:155-161).Sponsor(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:18) is a flat root belonging to exactly one event by scalarEventId(Sponsor.cs:45, with a private-setter[Navigation]Eventfor public visibility filtering atSponsor.cs:48-49). It carries aSponsorTierthat drives public placement, branding links, and the optional expo booth (IsExhibitor/BoothNumber,Sponsor.cs:52,58); itsIdis database-generated (Sponsor.cs:17) because sponsors are sold, not imported from Sessionize. Moving a sponsor between events is deliberately not an update:Updateomits the event entirely (Sponsor.cs:153-163).Activity(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:20) is the social and networking programme: the pre-conference party, the morning coffee connect, the after-party, the closing ceremony. It is deliberately not a session, and the type's own doc comment says why (Activity.cs:11-17): an activity has no room and no speakers, and it frequently happens at an external venue, so the venue travels on the activity itself (VenueName,VenueAddress,VenueUrlatActivity.cs:42,45,48) instead of being inherited from the event. ItsIdis database-generated (Activity.cs:19) because activities are planned, not imported; it belongs to one event by scalarEventIdwith a private-setter[Navigation]for visibility filtering (Activity.cs:54,57-58);StartTime/EndTimeare plain wall-clockDateTimes in the owning event's IANA zone, exactly likeSession.StartsAt, with the zone kept on the event and never repeated per row (Activity.cs:28-36); andSortOrderbreaks ties between activities starting at the same minute (Activity.cs:51). LikeSponsor, moving it between events is a create plus a delete rather than an update (Activity.cs:99,145).Category(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:16) ownsCategoryItem: the taxonomy roots ("Level", "Track", "Session format") and their selectable options. ItsIdis database-generated too (Category.cs:15); Sessionize imports supply explicit IDs viaIDENTITY_INSERT.Question(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:14) is a flat aggregate (no children); its answers live on the other aggregates as*QuestionAnswerjoin entities, keyed byQuestionId. Its three free-text enum-like fields (QuestionEntity,QuestionType,QuestionSource,Question.cs:20,23,32) are validated against allow-lists rather than modeled as C# enums, so an unfamiliar Sessionize value fails validation instead of breaking deserialization.SessionAiScore(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:13) is an AI-generated scorecard for a session: an overall score plus six per-criteria scores, alldecimal(SessionAiScore.cs:19-37), together with the model's reasoning and model identifier (SessionAiScore.cs:40-43), each score range-guarded to 1.0 through 10.0 by a private helper (SessionAiScore.cs:171-178) that the factory andUpdaterun throughResult.Combine(SessionAiScore.cs:91-97). It is stored one per session and replaced on re-scoring throughUpdate(SessionAiScore.cs:133), and it is the persistence side of the decision-support feature described below. That a scorecard is modeled as its own aggregate, referencing the Session by scalarSessionId(SessionAiScore.cs:16) rather than nesting under it, is a clean aggregate-boundary call: scores have an independent lifecycle (computed asynchronously, re-run on demand) and should not be loaded every time a Session is read. It is also the one root here that raises no domain events: no other part of the system reacts to a score being written.
The nine child entities all derive from AuditableBaseEntity<TIdentifierType> rather than from the
aggregate-root rung, because they have identity and audit but no independent lifecycle: they are
reached, created, and deleted only through their root (Room.cs:13, EventSpeaker.cs:14,
EventQuestionAnswer.cs:13, SessionSpeaker.cs:14, SessionCategoryItem.cs:14,
SessionQuestionAnswer.cs:13, SpeakerCategoryItem.cs:14, SpeakerQuestionAnswer.cs:13,
CategoryItem.cs:14). Five of them also implement
IReactivatable: Room, EventSpeaker,
SessionSpeaker, SessionCategoryItem, and SpeakerCategoryItem, exactly the five a Sessionize
re-import can bring back from a soft delete, which is what the Restore* methods below act on.
Three of the roots opt into the framework's change-history trail by also implementing
IAuditedEntity: Event (Event.cs:24),
Session (Session.cs:22), and Speaker (Speaker.cs:22). The in-code rationale is worth reading
(Event.cs:17-21, Session.cs:16-20, Speaker.cs:15-20): these three are written by organizers and
overwritten by the Sessionize sync, and "which edit moved this, and was it a person or the importer" is
a question that only a history answers. Sponsors, activities, categories, and questions do not carry
that cost.
The aggregate shape, taught once
Open any of the roots and you will see the same skeleton; this repetition is the point, and it is
what makes the per-type sections that follow read quickly. The shape, using Event as the
exemplar:
- Private-setter properties (
Name { get; private set; },Event.cs:27): state can only change through the aggregate's own methods, never by an outside caller assigning a property. This is encapsulation as a compile-time guarantee ([Rubric §4, Domain-Driven Design],[Rubric §1, SOLID]). - Backing-field collections exposed as
IReadOnlyCollection<T>(_roomsatEvent.cs:88becomesRooms => _rooms.AsReadOnly()atEvent.cs:92). Children can only be added, updated, or removed throughAddRoom/UpdateRoom/RemoveRoom-style methods that enforce invariants (for example the duplicate-name rejection atEvent.cs:695-712). Most collections are decorated[Navigation(IsCollection = true)]so the navigation-populator machinery (G11) eager-loads them, but two deliberately are not:Event.EventQuestionAnswers(Event.cs:100-112) andSession.SessionQuestionAnswers(Session.cs:90-104) opt out because those collections grow with attendance rather than with the schedule and were riding along on hot anonymous public reads that never render them; the session answers are also the one child collection here that is not public data (Session.cs:99-102). Handlers that genuinely need them pass an explicitincludes:list. That is a[Rubric §12, Performance & Scalability]decision expressed as a deliberately absent attribute. - A private EF Core constructor (
Event.cs:115, for materialization) plus a private state constructor (Event.cs:121) used only by the factory. - A static
Create(...)factory returningResult<T>(Event.cs:170): it validates invariants viaResult.Combine(...)before constructing anything (Event.cs:195-198), so an invalid aggregate is unrepresentable, then raises anAddeddomain event (Event.cs:222). TheisIdValueGenerated ? default : id!.Valuedance (Event.cs:202,218) reconciles database-generated IDs with explicitly supplied ones. Each root spells that reconciliation slightly differently:Speakergenerates a GUID when no id is supplied (Speaker.cs:161),Categorythrows for a missing id when identity is not database-generated (Category.cs:69), andActivityuses the plainEventform (Activity.cs:99). - Mutator methods (
UpdateatEvent.cs:247,Publish/UnpublishatEvent.cs:299,319,LinkUser/UnlinkUseron Speaker atSpeaker.cs:272,290) that re-validate, mutate, and raise anUpdatedevent. Lifecycle guards return failures rather than throwing: publishing an already published event yields the"Event.AlreadyPublished"invariant error (Event.cs:301-308). - An overridden
Delete()(Event.cs:355) that soft-deletes children first and the root last, combining all four results in oneResult.Combine(...):DeleteChildren<T, TId>(...)for rooms, event speakers, and event answers, thenbase.Delete()(the soft-delete from G02) atEvent.cs:361-364, with theDeletedevent raised only when the whole combination succeeded (Event.cs:367). The in-code comment states the reason for that ordering (Event.cs:357-359): a failing child leaves the cascade reported as a failure instead of a half-applied delete whose earlier children and root were already flagged.Sessiondoes the same for its three child collections (Session.cs:288-291,294),Categoryfor its items (Category.cs:107-108,111),SponsorandActivityhave nothing to cascade to and simply raise theirDeletedevents (Sponsor.cs:190-197,Activity.cs:180-188), andSpeakeruses its override for a different job: clearing the cross-context link while deliberately leaving its junction children alive so the Sessionize import can reactivate them in place (Speaker.cs:241-267). Soft-delete is the default everywhere ([Rubric §8, Data Architecture]: theIsDeletedflag plus EF Core global query filters, never a hardDELETE; ADR-005). - Restore methods for the Sessionize round-trip (
RestoreRoomatEvent.cs:465,RestoreEventSpeakeratEvent.cs:573,RestoreSessionSpeakerandRestoreSessionCategoryItematSession.cs:352,435, andRestoreSpeakerCategoryItematSpeaker.cs:352): a re-imported child that was previously soft-deleted is reactivated in place rather than re-inserted. A restore has to clear the same uniqueness bar as an add, which is whyRestoreRoomre-runs the duplicate-name check before reactivating (Event.cs:486), and it first refuses any room owned by a different event (Event.cs:474): room ids come from a global Sessionize sequence,Room.EventIdhas no setter (Room.cs:38), and adding a foreign room to this collection would let EF relationship fixup silently move the row (the comment spelling that out sits atEvent.cs:469-473). internal SetX(...)methods (Event.cs:525,607,681) delegating to the framework'sSetItemshelper: the hooks the navigation populators call to hydrate the read-only collections after a batch load.
Because the shape is identical, the child entities (Room, the *Speaker/*CategoryItem
joins, the three *QuestionAnswer types) and their *Changed domain events are documented as
sibling families in the sections that follow: taught once, then tabulated.
Invariants, business rules as testable units
Each aggregate has a co-located static invariant class, EventInvariants
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:14),
SessionInvariants, SpeakerInvariants,
SponsorInvariants, ActivityInvariants,
CategoryInvariants, and QuestionInvariants, whose
methods each return a Result and are combined with
Result.Combine(...) in the factory and mutators. They build on
CommonInvariants (G02) for the generic
string-not-empty and max-length checks (EventInvariants.cs:71-74) and add domain-specific rules.
They also carry the length constants, but note where those numbers actually originate: each
invariant constant is an alias of a constant declared on the matching DTO in Conference.Shared
(public const int NameMaxLength = EventDTO.NameMaxLength;, EventInvariants.cs:17-58, and the same
pattern at SessionInvariants.cs:16-34, SpeakerInvariants.cs:16-40, SponsorInvariants.cs:16-34,
ActivityInvariants.cs:16-28, CategoryInvariants.cs:18-24, QuestionInvariants.cs:16-25). The DTO
is the lowest layer the domain, the EF configuration, and the Blazor pages can all reach, so a field
cap is declared once on EventDTO and consumed by the domain rule, the column
constraint, and the input's character counter alike (EventInvariants.cs:9-12 records the reasoning).
The only length constants declared in Domain itself are the ones no DTO owns, the 4000-character
answer-value caps (EventInvariants.cs:59, SessionInvariants.cs:37, SpeakerInvariants.cs:43).
The domain-specific rules are where the ubiquitous language shows up. SessionInvariants holds the
BR-91 service-session guard (SessionInvariants.cs:94), the BR-49 status-eligibility check whose
failure code is "Session.StatusIneligible" (SessionInvariants.cs:109,112), the BR-122
zero-duration guard whose failure code is "Session.Duration.Invalid"
(SessionInvariants.cs:126-134), and the reserved manual id range 999_999_000 through 999_999_999
for sessions that did not come from Sessionize (SessionInvariants.cs:44,47, mirrored for rooms at
EventInvariants.cs:66,69 and for questions at
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:40,43).
QuestionInvariants validates the free-text enum-like fields against allow-lists
(QuestionInvariants.cs:31,34,37, checked at :71,86,101) and, for answers, dispatches on the
question type (QuestionInvariants.cs:118): Rating must parse as an invariant-culture integer 1
through 5 (QuestionInvariants.cs:133), Text is capped at 2000 characters
(QuestionInvariants.cs:28,147), and Email must parse as a System.Net.Mail.MailAddress
(QuestionInvariants.cs:163). ActivityInvariants is the compact newcomer: name, venue name, venue
address, and venue URL length checks plus a start-before-end time-range rule
(ActivityInvariants.cs:36,48,58,68,79). CategoryInvariants enforces case-insensitive uniqueness of
an item name within its category (BR-138,
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:44),
and its in-code note explains why the exclusion parameter is nullable rather than defaulted: a
database-generated CategoryItem id is 0 until the save, so a default exclusion would silently
exempt every unsaved sibling (CategoryInvariants.cs:49-51). Centralizing each rule as a named,
side-effect-free method is what makes the domain exhaustively unit-testable ([Rubric §14, Testability]), and the error codes ("Event.AlreadyPublished" at Event.cs:304,
"Session.StatusIneligible" at SessionInvariants.cs:112) are the business vocabulary. The
recurring // BR-NN comments are traceability links back to the business-requirements catalogue.
A nuance worth flagging: the Status field on Session is free text, imported verbatim from Sessionize
(Session.cs:37), and SessionStatuses
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:14) is the
constant catalogue of the recognized values (Accepted, Waitlisted, Accept_Queue, Nominated,
Decline_Queue, Declined, at SessionStatuses.cs:17-32, enumerated for organizer filter dropdowns
at SessionStatuses.cs:37) plus the IsEligible(...) rule. Read that rule carefully, because it is an
allow-list, not a deny-list: only Accepted, or an unset status (organizer-created sessions never
carry one), is eligible for public display, bookmarking, and feedback; every other value, known or
unknown, is ineligible (BR-49, SessionStatuses.cs:53-55, with the reasoning at
SessionStatuses.cs:8-13). Adding a constant to this class therefore does not make that status
publicly visible, which is the safe default for a free-text field fed by an external system. Using
const string values instead of a C# enum means an unrecognized Sessionize status does not break
deserialization; the type lives in Domain because eligibility is a domain rule, and it is referenced
from the cross-module bookmark validation too. [Rubric §8, Data Architecture] (deliberate handling of
externally sourced data).
Domain events and the outbox spine
Every state-changing method raises a domain event through the inherited AddDomainEvent(...), and the
sixteen events come in two shapes with two different base types. The seven aggregate-level ones,
EventChanged, SessionChanged, SpeakerChanged,
CategoryChanged, QuestionChanged,
SponsorChanged, and ActivityChanged, derive from
EntityChangedEvent<TIdentifierType>
and carry the DomainEntityState
(Added/Updated/Deleted) plus a friendly label, and sometimes one extra correlating field:
SessionChanged also carries the parent EventId
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/DomainEvents/SessionChanged.cs:13-18).
The nine child-level ones, RoomChanged, EventSpeakerChanged,
EventQuestionAnswerChanged,
SessionSpeakerChanged,
SessionCategoryItemChanged,
SessionQuestionAnswerChanged,
SpeakerCategoryItemChanged,
SpeakerQuestionAnswerChanged, and
CategoryItemChanged, derive from
BaseDomainEvent instead, because a child change is not a
change of that root's identity: they carry both the parent and child IDs (for example
RoomChanged(state, Id, room.Id, room.Name) raised at Event.cs:444, declared at
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/RoomChanged.cs:13-18)
so a consumer can target the precise change. These are intra-module domain events: they ride the
outbox (ADR-003) but are consumed
inside Conference. They do not cross the wire to other services; that is the job of integration events,
and only those carry an explicit
EventNameAttribute wire name.
The flow is exactly the outbox spine from G04: a mutator buffers the event
on the aggregate; on SaveChangesAsync the domain-event save-changes interceptor serializes it into an
OutboxMessage row in the same transaction; dual dispatch
then delivers it at least once. Nothing in this chapter's code does any dispatching; the aggregates
only declare what happened, which is the Clean Architecture division of labor ([Rubric §6, CQRS & Event-Driven]). One domain detail matters for the cross-context link: Speaker.Delete() captures the
previous LinkedUserId before clearing it and passes it into the Deleted
SpeakerChanged event (Speaker.cs:254,261,263), whose optional
PreviousLinkedUserId payload field
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/DomainEvents/SpeakerChanged.cs:20)
exists precisely so the cross-context cleanup handler has what it needs even though the field is
already nulled within Conference (BR-70).
The cross-aggregate cascade: a pure domain service
One business rule cannot live inside a single aggregate: deleting an Event must also delete every
Session belonging to it (BR-127), every Sponsor sold against it, and every Activity planned for
it, but sessions, sponsors, and activities are separate aggregates (referenced by EventId, not
owned). Putting a List<Session> inside Event would violate the aggregate boundary. The answer is a
domain service, IEventCascadeDeletionDomainService
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/IEventCascadeDeletionDomainService.cs:14)
and its implementation EventCascadeDeletionDomainService
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventCascadeDeletionDomainService.cs:15),
a pure, infrastructure-free coordinator that takes the pre-fetched Event plus its already-loaded
Session, Sponsor, and Activity collections (IEventCascadeDeletionDomainService.cs:27-31) and
orchestrates the deletes: soft-delete each session first (BR-55 cascades to its children), then each
sponsor, then each activity, then the event itself (BR-72 cascades to rooms, event speakers, and event
answers) (EventCascadeDeletionDomainService.cs:27-54). The ordering is what makes the failure path
safe: the first child that refuses to delete short-circuits the cascade and returns its own failure
unchanged, so the event is never deleted and the caller (which saves only on success) discards the
aborted in-memory mutations rather than persisting a half-deleted graph
(EventCascadeDeletionDomainService.cs:24-51). Activities were folded into the same cascade for the
reason recorded beside the loop: leaving them behind would orphan rows the public activities page still
reads (EventCascadeDeletionDomainService.cs:43-45). This is [Rubric §4, Domain-Driven Design]'s
textbook "domain service for behavior that spans aggregates and belongs to no single one," and [Rubric §3, Clean Architecture]'s purity discipline: the service does no I/O; the application layer fetches
the aggregates and saves them. It is the highest-level type in the chapter precisely because it depends
on four aggregates at once.
Read models and the AI decision-support feature
The largest cluster in Conference.Shared is the DTO layer, the wire contracts that decouple the
API from the domain entities ([Rubric §9, API & Contract Design];
ADR-001 chose manual/Mapperly
mapping over reflection-based AutoMapper). Most are straightforward projections:
EventDTO, SessionDTO, SpeakerDTO,
SponsorDTO, ActivityDTO,
ConferenceCategoryDTO, CategoryItemDTO,
QuestionDTO, RoomDTO, and the per-child join DTOs
(EventSpeakerDTO, SessionSpeakerDTO,
SessionCategoryItemDTO,
SpeakerCategoryItemDTO, and the three *QuestionAnswerDTO records:
EventQuestionAnswerDTO,
SessionQuestionAnswerDTO,
SpeakerQuestionAnswerDTO), plus the speaker-facing feedback shapes
SessionFeedbackDTO with its RatingQuestionSummary
and TextQuestionResponses members (BR-210,
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:6,22,38).
They carry the entity's Id via the framework's
IBaseDTO<TIdentifierType> contract and
required init-only properties: read contracts, immutable after construction. Five of them also
implement IConcurrencyAware and round-trip the
RowVersion token
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventDTO.cs:16,52, and the same
pair on Sessions/SessionDTO.cs:15,42, Speakers/SpeakerDTO.cs:18,58, Sponsors/SponsorDTO.cs:15,42,
and Activities/ActivityDTO.cs:15,36). The client sends that token straight back in the If-Match
header rather than in a body field: the publish and unpublish endpoints are marked
SupportsIfMatchAttribute and read the
required token from the header before building their command
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:265,273,298,306),
so a transition decided against a stale view surfaces as a conflict instead of applying silently, and a
request with no If-Match at all is refused outright
(ADR-035). Alongside those sit
the small task-shaped contracts: LinkUserRequest (the manual speaker-to-user link
body, BR-209,
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/LinkUserRequest.cs:6),
RefreshFromSessionizeResultDTO (per-entity synced counts, the
BR-136 skipped-soft-deleted count, and non-fatal warnings,
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RefreshFromSessionizeResultDTO.cs:7,28,31),
and the glanceable NowNextDTO/NowNextSessionDTO snapshot behind
the public now-next endpoint (the Android home-screen widget payload, carrying both event-local wall
clock and UTC instants,
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/NowNextDTO.cs:14,29).
A distinct and more interesting subgroup is the DecisionSupport namespace: read models built
purely to help an organizer curate a conference.
SessionSelectionDashboardDTO
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/SessionSelectionDashboardDTO.cs:8)
is the composite; for one event it carries the total, accepted, accept-queue, pending, and declined
counts (SessionSelectionDashboardDTO.cs:17-29), a
CategoryDistributionDTO (how sessions spread across tracks and levels,
itself built from CategoryGroupDistribution and
CategoryItemDistribution), a
SpeakerSessionOverlapDTO (speakers with multiple submissions, via
MultiSessionSpeaker and SpeakerSessionSummary),
per-tier SpeakerLocalitySummary counts
(SessionSelectionDashboardDTO.cs:38,45), and a list of SessionAiScoreDTO
(SessionSelectionDashboardDTO.cs:41). The locality breakdown is the Atlanta-versus-elsewhere signal
behind the local-speaker preference, and it is derived from a locality category in the taxonomy
rather than from a field on Speaker: the dashboard handler resolves each speaker's tier through the
SpeakerLocalityHelper lookup
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:75-76,286).
ContentSimilarityDTO and its SimilarSessionPair
rows (near-duplicate talks, scored 0.0 to 1.0 with the shared category items and keywords that drove the
score, ContentSimilarityDTO.cs:34-41) are not members of the composite record: they are served by
their own endpoint on the same controller
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:82).
The AI scores are produced by an Anthropic-backed scoring service in Conference.Infrastructure
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AnthropicScoringService.cs:19,
outside this chapter) and persisted as the SessionAiScore aggregate;
ScoreEventSessionsResultDTO reports a batch run's scored and failed
counts
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/SessionAiScoreDTO.cs:68-75).
The whole organizer workflow is guarded by the conference:session-selection:manage capability
permission catalogued in ConferencePermissions
(ConferencePermissions.cs:30), applied once at the controller level
(SessionSelectionController.cs:29), not by a feature flag. The one flag the module does carry,
ConferenceFeatures.SessionizeIntegration
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:15), gates only
the Sessionize external sync that seeds the raw session data this dashboard then analyzes: the
RefreshFromSessionizeCommand implements IFeatureGated
and returns the flag name from its FeatureName property
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13,19),
so the decorator pipeline short-circuits it when the flag is off ([Rubric §12, Performance & Scalability Concerns], ADR-031). The
scoring and dashboard handlers are not flag-gated.
Authorization vocabulary and current-event selection
Two more Shared helpers deserve a mention because they encode policy the whole module relies on.
ConferencePermissions
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:9)
is the catalogue of the module's nine capability permissions (conference:events:manage,
conference:sessions:manage, conference:sponsors:manage, conference:activities:manage, and so on,
ConferencePermissions.cs:12-36), the stable string identifiers endpoints require via
HasPermissionAttribute rather than by role name. The All
and ContentManagement subsets (ConferencePermissions.cs:39,57) let a role grant an entire
capability set or the narrower catalog-curation slice (sessions, speakers, sponsors, activities, and
the category taxonomy) at once, a distinction capability checks express centrally and role checks
cannot. This is the permission-based authorization story ([Rubric §11, Security],
ADR-020), decided by the
role-to-permission grants declared in the module's registration rather than scattered across
controllers. Beside it sits ConferenceReadAudience
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferenceReadAudience.cs:23),
the answer to the other question a caller raises, not "may I change this" but "how much of the catalog
may I see": exactly two audiences exist, the privileged readers
(RoleNames.Organizer and .ContentEditor,
ConferenceReadAudience.cs:26-30) and everyone else, and naming them once is what keeps the
output-cache bypass list and the API-layer visibility checks from ever disagreeing. A third,
partially-privileged audience would need its own cache key, which is why the type's own remarks tell you
to check the cache policies before extending the list (ConferenceReadAudience.cs:17-21).
CurrentEventSelector
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:12) is
the pure, generic helper every landing surface uses to pick which published event to feature: the
event live now (soonest to end), else the next upcoming (soonest to start), else the most recently
ended (CurrentEventSelector.cs:40-54). It shares the exact live-window math the backend enforces:
StartDate at 00:00 local through EndDate + 1 day at 00:00 local (exclusive), converted from the
event's IANA time zone to UTC (CurrentEventSelector.cs:66-76). There is no unknown-zone fallback and
the code says why: EventInvariants.EnsureTimeZoneIsValid guards every write path, so the id always
resolves (CurrentEventSelector.cs:57-60). One subtlety earns its own method: both window boundaries
land on local midnight, which does not exist in zones that spring forward at 00:00, so ToUtc shifts an
invalid wall-clock time forward by an hour rather than letting TimeZoneInfo.ConvertTimeToUtc throw
(CurrentEventSelector.cs:89-100, the shift itself at :92-95). Because the selector is generic over
the event model, each consumer passes its own DTO plus accessor delegates, so the selection rule lives
in one tested place rather than being re-derived per surface;
CurrentEventDefaults
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventDefaults.cs:8,17-23)
is the thin wrapper that binds those delegates for the common EventDTO shape.
Crossing the module boundary: contracts, stubs, and integration events
Conference does not live alone. Three kinds of connection point join it to other modules, and all live
in Conference.Shared so neither side reaches into the other's domain ([Rubric §7, Microservices Readiness], [Rubric §3, Clean Architecture]):
Synchronous bookmark validation (inbound). The Engagement module needs to validate that a session is bookmarkable (exists, not a service session per BR-91, eligible status per BR-49) and to enumerate a session's IDs by event (BR-58). It depends on the
ISessionBookmarkValidationServiceinterface (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/ISessionBookmarkValidationService.cs:11,20,31), which is markedServiceContractAttribute(ISessionBookmarkValidationService.cs:10) so the contract-purity fitness tests hold it to entity-free, extraction-safe signatures. It is implemented inConference.Applicationin process, or by a gRPC adapter when the modules run as separate services (ADR-007). When Conference is disabled in a host,DisabledSessionBookmarkValidationService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DisabledSessionBookmarkValidationService.cs:30) is registered as a null-object stub that approves every validation and returns an empty ID set (DisabledSessionBookmarkValidationService.cs:33-38): graceful degradation rather than a missing-dependency crash. The registration point is the module itself,ConferenceModule.RegisterDisabledStubs(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:21-25), anIModulehook the host calls when it composes without Conference.Synchronous live-layer validation (inbound). The Engagement conference-day live layer asks Conference four questions, and the four members of
IEventLiveValidationService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/IEventLiveValidationService.cs:13, also a[ServiceContract]at:11) are exactly those questions: is this event published and inside its live window (EventLiveInfo,IEventLiveValidationService.cs:23); for a session, who are the assigned speakers (BR-236), is it a plenum session, and what is the event's question moderation default (SessionLiveInfo,IEventLiveValidationService.cs:34); for a sponsor scanned from a printed booth QR code, does it exist and belong to a published event (SponsorLiveInfo,IEventLiveValidationService.cs:45); and which session is a given room hosting right now (RoomSessionInfo,IEventLiveValidationService.cs:61-64), so a check-in never has to trust a client-supplied session id. Each returns a snapshot record, never a Conference domain entity. Note where the policy line falls on that last one: the grace window travels in the request as a parameter rather than living in Conference config, because how early a session counts as current is check-in policy and Conference only answers the schedule question (IEventLiveValidationService.cs:52-55,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Rooms/RoomSessionInfo.cs:10-14). The moderation default is theQuestionModerationDefaultenum (Pending = 0/Approved = 1, BR-233,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/QuestionModerationDefault.cs:7-13) carried on theEvent(Event.cs:80, defaulted toPendingin bothCreateandUpdate,Event.cs:181,257). The disabled stub,DisabledEventLiveValidationService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/DisabledEventLiveValidationService.cs:23), deliberately fails open on all four: an always-open window and a published flag for events and sponsors, a default event id with no assigned speakers for sessions, and the room's own id echoed back as the session id (DisabledEventLiveValidationService.cs:26-63), so the live-layer handlers can run without an in-process Conference module, at the cost of skipping those checks until the host is wired to the Conference gRPC adapter.Asynchronous notifications (outbound). Two families of integration event leave the module. When Conference links or unlinks a Speaker to or from an Identity
User(the manual link command, or the automatic email-match triggered by Identity'sUserRegisteredevent), it publishesSpeakerLinkedToUser/SpeakerUnlinkedFromUser(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerLinkedToUser.cs:22and.../IntegrationEvents/SpeakerUnlinkedFromUser.cs:19), records extendingBaseIntegrationEventand carrying just the two identifiers. Identity subscribes and sets or clearsUser.LinkedSpeakerId, so the next JWT refresh carries thespeaker_idclaim (BR-209). When an attendee submits feedback, the answer handlers raiseSessionFeedbackSubmitted/EventFeedbackSubmitted(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/IntegrationEvents/SessionFeedbackSubmitted.cs:21,.../Events/IntegrationEvents/EventFeedbackSubmitted.cs:20), which Engagement consumes to award points. All four declare their wire name explicitly withEventNameAttribute, versioned ("Conference.SpeakerLinkedToUser.v1"atSpeakerLinkedToUser.cs:21,"Conference.SessionFeedbackSubmitted.v1"atSessionFeedbackSubmitted.cs:20), so a rename of the C# type cannot silently break a subscriber. Two details in the two feedback records are load-bearing: they are raised on the create path only, never on the BR-107 update path of the feedback upsert, and the consumer is independently idempotent because one submitted form writes one row per question and therefore raises the event once per new answer (SessionFeedbackSubmitted.cs:9-14). They are also added to the aggregate pre-save withAddDomainEvent, so the outbox captures them atomically with the answer in the sameSaveChangesAsync(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:113). All four are the eventually consistent replacement for what would otherwise be direct cross-module service calls: the links and the points ledger survive the service split because they travel as events over the broker (ADR-006/ADR-008).
End-to-end: one organizer action
To see the chapter cooperate, follow an organizer renaming a room on an event. The application handler
loads the Event aggregate (with its Rooms hydrated by the navigation populator), calls
event.UpdateRoom(...) (Event.cs:422), which routes through the private GetRoomOrNotFound helper
(Event.cs:431, implemented at Event.cs:714-717 and delegating to the framework's
GetChildOrNotFound, so a missing or soft-deleted room comes back as a NotFound
Result rather than an exception), re-checks the
case-insensitive room-name uniqueness rule that mirrors the database index (Event.cs:436, implemented
at Event.cs:695-712), delegates to the child's own Room.Update(...) (which validates its
invariants, Event.cs:440), and on success raises a RoomChanged Updated event
(Event.cs:444). The handler calls SaveChangesAsync; the interceptor writes the RoomChanged to the
outbox in the same transaction; and because the command itself declares
ICacheInvalidating with the Event type's full name
as its prefix
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommand.cs:23,26),
the decorator pipeline evicts the event's cached reads so the next query is fresh. No exception was
thrown on the expected not-found path, no child was mutated from outside its aggregate, no event was
hand-dispatched, and the same code path would behave identically whether Conference runs in the monolith
or as its own service, which is exactly the property the framework groups (G01 through G14) exist to
provide, here made concrete in a domain you can reason about. For the why behind each design choice,
ADR-001 (manual mapping),
ADR-002 (navigation populators),
ADR-003 (outbox),
ADR-005 (soft-delete versus
erasure),
ADR-006/ADR-007/ADR-008
(database-per-service, gRPC extraction, service topology),
ADR-020
(permission-based authorization),
ADR-031 (feature flags), and
ADR-035 (optimistic concurrency)
are the primary references; the business rules themselves are catalogued in ADC's specifications guide.
AssemblyReference, ClassReference
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/AssemblyReference.cs:5· Level 0 · class (static) + class
- What it is: the two assembly-marker types that give
typeof()-based assembly scanning a stable handle on theMMCA.ADC.Conference.Domainassembly. No behavior, no state beyond the reflection handle.
| Type | File:Line | Notes (what differs) |
|---|---|---|
AssemblyReference |
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/AssemblyReference.cs:5 |
static class exposing Assembly (typeof(AssemblyReference).Assembly, line 7) and AssemblyName (line 8, Assembly.GetName().Name ?? string.Empty) |
ClassReference |
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/AssemblyReference.cs:11 |
a one-line empty public class ClassReference { }, a handle for APIs that want a type rather than an Assembly |
- Depends on: nothing first-party; only
System.Reflection(AssemblyReference.cs:1). - Concept introduced, the assembly-marker pattern.
[Rubric §2, Design Patterns](assesses whether the patterns in use are idiomatic and solve a real problem): instead of hard-coding an assembly-name string, a scanner takes atypeof(...)from a type it knows lives in the target assembly, so renaming the assembly cannot silently break discovery. Every layer of every ADC module ships this same pair (see the sibling pairs in group-18 Conference.Application, group-19 Conference.Infrastructure, and group-20 Conference.API), so registration and discovery code reads the same way in every project. MMCA.Common ships the same pair in its own layers (for exampleMMCA.Common/Source/Core/MMCA.Common.Domain/AssemblyReference.cs:8,18), and its doc comment there records the split explicitly:ClassReferenceis the anchor for the case where a static type cannot be used. - Walkthrough:
AssemblyReference.Assembly(AssemblyReference.cs:7) is apublic static readonly Assembly;AssemblyName(AssemblyReference.cs:8) is its short name, falling back tostring.Emptywhen reflection returns null.ClassReference(AssemblyReference.cs:11) has no members at all. - Why it's built this way: a
typeof()handle is refactor-safe where a magic string is not, and a non-staticClassReferencecan be passed where a static class cannot. A C# static type is not a legal generic type argument, so a generic scanning API such asservices.ScanModuleApplicationServices<ClassReference>()(used by the Application-layer sibling atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133) needs the non-static form. - Where it's used: the Domain pair has no call site in
MMCA.ADC/Sourcetoday. The layer pairs that are actually consumed are the Application one (Conference.Application/DependencyInjection.cs:130) and the framework's own (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:126,MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:51). The Domain pair exists so the layer-parallel convention holds across all five layers of the module. - Caveats / not-in-source: whether the convention is enforced (an architecture fitness rule requiring one pair per project) is not visible from these files; no test in
MMCA.ADC/Testsreferences either type.
ConferenceFeatures
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:8· Level 0 · class (static)
- What it is: the feature-flag name catalog for the Conference module. It holds exactly one constant today:
SessionizeIntegration = "Conference.SessionizeIntegration"(ConferenceFeatures.cs:15), which gates the Sessionize external-data sync capability. - Depends on: nothing first-party.
- Concept introduced, feature flags as named constants.
[Rubric §6, CQRS & Event-Driven Design](assesses whether cross-cutting behavior such as flags and configuration is centralized rather than scattered). The constant's value matches a key under the"FeatureManagement"configuration section, and per the class doc comment (ConferenceFeatures.cs:3-7) it is consumed with[FeatureGate]attributes and theIFeatureGatedmarker interface. Centralizing the string here means the flag name is written once: a typo cannot silently split one flag into two, one of which is never configured and therefore always off. The"{Module}.{Feature}"naming convention keeps flags from different modules unambiguous inside one configuration file. The mechanism itself is ADR-031. - Walkthrough: a single
public const string(ConferenceFeatures.cs:15). The member doc comment (ConferenceFeatures.cs:10-14) records the runtime contract: when the flag is disabled,RefreshFromSessionizeCommandshort-circuits with a failure result and organizers manage event data manually instead of syncing. - Why it's built this way: putting the Sessionize sync behind a flag lets organizers turn the integration off (for example during a Sessionize API maintenance window) through configuration, with no redeploy.
- Where it's used:
RefreshFromSessionizeCommandimplementsIFeatureGated(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13) and returns this constant from itsFeatureNameproperty (RefreshFromSessionizeCommand.cs:19), so the pipeline decorator (G05), not the handler body, does the gating.
ConferencePermissions
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Authorization·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferencePermissions.cs:9· Level 0 · class (static)
- What it is: the Conference module's capability permission catalog: the stable string identifiers its endpoints require through
[HasPermission(...)]instead of role names. Ninemanagecapabilities plus two curated groupings of them. - Depends on: nothing first-party.
- Concept reinforced, capability permissions over role names (the consumer side).
[Rubric §11, Security](assesses whether authorization is expressed as fine-grained capabilities rather than coarse role checks scattered through controllers). This is ADC's use of the framework mechanism taught in G08 (IPermissionRegistry,HasPermissionAttribute) and decided in ADR-020. The class doc comment (ConferencePermissions.cs:3-8) states the two properties that make the catalog work: who-can-do-what is decided by the role-to-permission grants declared in the module's registration rather than by controller attributes, and the values are deliberately stable strings because they may end up inside tokens or logs. - Walkthrough
- Nine
public const stringcapabilities:EventsManage=conference:events:manage(ConferencePermissions.cs:12),SessionsManage(:15),SpeakersManage(:18),RoomsManage(:21),CategoriesManage(:24),QuestionsManage(:27),SessionSelectionManage=conference:session-selection:manage(:30),SponsorsManage=conference:sponsors:manage(:33), andActivitiesManage=conference:activities:manage(:36). The{module}:{resource}:{verb}shape keeps the namespace collision-free across modules. All(ConferencePermissions.cs:39-50): anIReadOnlyList<string>collection expression naming every one of the nine, for granting an entire capability set to a role in one line.ContentManagement(ConferencePermissions.cs:57-64): the catalog-curation subset,SessionsManage+SpeakersManage+CategoriesManage+SponsorsManage+ActivitiesManage. Its doc comment (ConferencePermissions.cs:52-56) is the load-bearing part: a content-editor role holds these but not event structure, rooms, questions, or session selection, a distinction that capability checks express centrally and role checks cannot.
- Nine
- Why it's built this way: a per-module catalog keeps each module's capability vocabulary self-contained (the Conference module can add a capability without touching Identity), and pairing the constants with named subsets makes the grants read declaratively at the registration site instead of as a hand-maintained string list. Adding a capability is then a two-line change: the constant, and its entry in whichever subsets should carry it.
- Where it's used: every Conference controller's
[HasPermission(...)]attributes, and the role-to-permission grants in the module's API registration:OrganizerandAdmineach receive[.. ConferencePermissions.All](MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:43-44) andContentEditorreceives[.. ConferencePermissions.ContentManagement](DependencyInjection.cs:50), all throughRoleNames.
SessionStatuses
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:14· Level 0 · class (static)
- What it is: the catalog of the six recognized Sessionize session-status strings, plus the predicate
IsEligible()that decides public display, attendee bookmarking, and post-session feedback (BR-49).Session.Statusis a free-text field imported from Sessionize; this class is the one place in the domain that gives those strings behavioral meaning. - Depends on: nothing first-party (only
StringComparisonfrom the BCL). - Concept introduced, the allow-list over external free text.
[Rubric §4, Domain-Driven Design](assesses whether the model mirrors the business and speaks its language) and[Rubric §8, Data Architecture](assesses deliberate handling of externally-sourced data). Two design choices are worth reading carefully:const string, not a C#enum. Sessionize can return a status this list has never seen. String constants mean an unknown value simply fails to match; a deserialization-bound enum would have to decide what to do with it.- Visibility is an allow-list decided centrally, not a per-constant flag. The remarks (
SessionStatuses.cs:8-13) say so outright: a status is publicly visible only if it isAcceptedor unset, so adding a constant here does not make it publicly visible. That is the safe default for a field fed by an external system: the failure mode of a new Sessionize status is "not shown yet", not "leaked".
- Walkthrough
- Six
const stringvalues:Accepted(SessionStatuses.cs:17),Waitlisted(:20),AcceptQueue="Accept_Queue"(:23),Nominated(:26),DeclineQueue="Decline_Queue"(:29),Declined(:32). Note that two of the literal values carry an underscore the C# identifier does not. AllKnownStatuses(SessionStatuses.cs:37-45): astatic readonly IReadOnlyList<string>collection expression of all six, so organizer filter dropdowns do not re-list the constants by hand.IsEligible(string? status)(SessionStatuses.cs:54-56): returns true whenstatus is nullor equalsAcceptedcase-insensitively (StringComparison.OrdinalIgnoreCase). Everything else is ineligible, known and unknown alike. The null branch is not an oversight: organizer-created sessions never carry a status, and the doc comment (SessionStatuses.cs:47-53) names them.
- Six
- Why it's built this way: keeping the eligibility predicate in the domain (not in a handler, a query, or the UI) means every consumer applies the identical definition, and tightening the rule is a one-line edit here rather than a search across layers.
- Where it's used:
SessionInvariants.EnsureStatusIsEligible(SessionInvariants.cs:110); the calendar-export filter, whose doc comment names this the single source of truth (CalendarExportMapper,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:21,28); the organizer session-selection dashboard's status bucketing (GetSessionSelectionDashboardHandler,.../DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:81-85,218,294-297,324-335); and the other decision-support handlers, which reuse the same constants for their own bucketing (.../DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:104-115,.../GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:113,.../GetContentSimilarity/GetContentSimilarityHandler.cs:31), all G18. - Caveats / not-in-source: some consumers deliberately do not call
IsEligible.PublicSessionStatusSpecificationrestates the same rule as an expression tree,s => s.Status == null || s.Status == SessionStatuses.Accepted(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:24), because a method call cannot be translated to SQL; its own doc comment (:16) records the reason. Two UI files carry a comment pointing atIsEligibleas the source of truth while restating the rule locally, because the UI layer depends onSharedonly and cannot referenceDomain(.../MMCA.ADC.Conference.UI/Pages/Public/PublicSessionDetail.razor.cs:89,.../PublicSessionListView.razor.cs:100). Those definitions are kept in sync by hand.
ConferenceReadAudience
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Authorization·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Authorization/ConferenceReadAudience.cs:23· Level 1 · class (static)
What it is: the Conference module's read-audience catalog. One member,
PrivilegedRoles, names the two roles that read the whole catalog:RoleNames.OrganizerandRoleNames.ContentEditor(ConferenceReadAudience.cs:26-30). Everyone else (attendees, speakers, anonymous visitors) sees the public projection: accepted-or-unset sessions (BR-49), published events (BR-108), and their speakers (BR-239), exactly as the class doc comment states (ConferenceReadAudience.cs:5-9).Depends on:
RoleNamesfromMMCA.Common.Shared.Auth(ConferenceReadAudience.cs:1), and nothing else. That is why it can live inSharedand be referenced from the Blazor UI as easily as from the service host.Concept introduced, the read audience as a thing distinct from the capability permission.
[Rubric §11, Security](assesses fine-grained, data-scoped authorization rather than scattered coarse role checks). Two different questions get asked in this module, and this type answers only the second:- "May this caller change X?" is a capability question, answered by
ConferencePermissionsand enforced per endpoint with[HasPermission(...)]. - "How much of the catalog may this caller see?" is a read-audience question. It cannot be a per-endpoint attribute, because the answer changes the rows rather than the verdict: the same anonymous-allowed GET must return a narrower list. So the audience is declared once, here, and every read path compares against it.
The API-layer helper that wraps it spells the boundary out in its doc comment: the check is about read visibility, not authorization, and mutations stay gated by capability permissions that a role check must never stand in for (
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Authorization/CurrentUserServiceExtensions.cs:16-25).[Rubric §12, Performance & Scalability]applies for a less obvious reason: the same list drives the output-cache bypass introduced by ADR-040, so this audience definition doubles as a cache-correctness invariant (see Where it's used).- "May this caller change X?" is a capability question, answered by
Walkthrough: one member.
PrivilegedRoles(ConferenceReadAudience.cs:26-30) is astatic IReadOnlyList<string>initialized with a collection expression of the two role-name constants. There are no methods and no state; callers do the matching themselves withAny(...IsInRole).Why it's built this way: the remarks (
ConferenceReadAudience.cs:10-21) name the exact failure a single declaration prevents. The output-cache bypass list and the API-layer visibility checks must name the same roles; if the two lists drifted apart, a privileged caller's everything-inclusive response would land in a shared public cache entry and then be served to anonymous visitors. Declaring the audience once makes that drift impossible instead of merely unlikely. The second paragraph records what keeps the list at exactly two entries: a third, partially privileged audience would need its own cache key, so extending this list means revisiting the cache policies in the Conference service first.Where it's used: three layers, one definition.
- The Conference service host spreads it into
adminBypassRoles(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:215) and hands that array to ten named output-cache policies (Program.cs:216-244:ConferencePublicCache,EventsCache,SessionsCache,SpeakersCache,RoomsCache,CategoriesCache,QuestionsCache,SponsorsCache,ActivitiesCache,BookmarkCountsCache). The comment directly above states the single-source-of-truth rule and its consequence: if the two lists ever named different roles, a privileged payload would be cached and served to the public (Program.cs:212-214). One policy deliberately takes no bypass list,NowNextCache(Program.cs:232), because its payload is identical for every role. - The API layer wraps it as the
ICurrentUserServiceextensionIsPrivilegedConferenceReader()(CurrentUserServiceExtensions.cs:24-25), which the controllers use to decide whether to apply a public filter specification at all:EventsControllerpicksnullor aPublishedEventSpecificationfrom it (.../Controllers/EventsController.cs:75, with a second guard at:141), andSessionsController(:60),SpeakersController(:65),SponsorsController(:52),ActivitiesController(:52),RoomsController(:104),SessionSpeakersController(:59),SessionCategoryItemsController(:59),SpeakerCategoryItemsController(:59), andEventSpeakersController(:58) each expose it as a privateIsPrivilegedproperty. - The Blazor UI reads it directly when sizing its filters and detail views:
.../MMCA.ADC.Conference.UI/Pages/Public/PublicSessionList.razor.cs:121,.../PublicSpeakerList.razor.cs:101,.../PublicEventList.razor.cs:77,.../PublicEventDetail.razor.cs:64, and.../PublicSpeakerDetail.razor.cs:205.
The rows themselves are narrowed one layer up by
PublicConferenceVisibilityand the public filter specifications built on it (G18).- The Conference service host spreads it into
Caveats / not-in-source: case-insensitive role comparison is a property of
ICurrentUserService.IsInRole, not of this type. Whether a given JWT actually carries one of these roles is decided by the Identity module and is not visible from this file.
SessionAiScore
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:13· Level 5 · class (sealed)
- What it is: an aggregate root holding the AI-generated score for one session across seven criteria (overall, topic relevance, description quality, novelty, actionable takeaways, depth or insight quality, credibility and experience), plus the model's free-text
Reasoning, theModelUsedidentifier, and thePromptVersionof the scoring contract that produced the numbers. One live score per session. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>bound toSessionAiScoreIdentifierType,IdValueGeneratedAttribute, andResult/Error(G01). The alias resolves toint(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:13), per ADR-048. - Concept: the private-constructor plus
static Result<T> Createfactory pattern was introduced in G02. What this type adds to the discussion is where you validate machine output.[Rubric §4, DDD]and[Rubric §11, Security]overlap here: the 1.0 to 10.0 range check runs inside the domain, so a hallucinated or out-of-range model response is rejected before it can reach the database, even though nothing about the data's origin is visible to the entity. The[IdValueGenerated]attribute (SessionAiScore.cs:12) marks the identity as database-generated. StoringModelUsedandPromptVersionon the row is the governance half of the same idea: the entity cannot tell whether a number is trustworthy, so it records exactly which model and which prompt contract produced it, per ADR-111. - Walkthrough
- Eleven
private setproperties (SessionAiScore.cs:16-52): the FKSessionId, sevendecimalscores, theReasoning/ModelUsedtext, andPromptVersion(:52).decimalrather thandoublekeeps the stored values exactly as the model reported them. PromptVersionis a dated string in the shapeyyyy-MM-dd.N, stored besideModelUsedso a row says which reviewer brief produced it: a prompt revision re-bases every number the dashboard shows, and without the column nothing on the row records which rules applied. Rows written before the column existed carrylegacy(SessionAiScore.cs:45-51).- The EF parameterless constructor (
SessionAiScore.cs:55-60) seedsReasoning,ModelUsedandPromptVersiontostring.Empty, satisfying non-nullable reference types without an= null!escape hatch. Create(SessionAiScore.cs:77-117): takes the seven scores plusreasoning,modelUsedandpromptVersion(:86-88), combines sevenEnsureScoreInRangechecks throughResult.Combine(:90-97) so a caller sees every out-of-range field at once rather than the first; on failure it returnsResult.Failure<SessionAiScore>(result.Errors)(:100), otherwise it constructs withId = default(:104) and leaves identity to the database.Update(SessionAiScore.cs:133-169): re-runs the identical seven checks (:145-152), then replaces every score plus reasoning, model and prompt version (:157-166).EnsureScoreInRange(SessionAiScore.cs:171-178): a private helper using the C# relational patternscore is >= 1.0m and <= 10.0m(:172), shared by bothCreateandUpdate, so the range exists once. The failure carries the stable codeSessionAiScore.OutOfRange(:175) and a message built withstring.Create(CultureInfo.InvariantCulture, ...)(:176) so a machine-readable diagnostic does not change shape with the request culture.- Note what is not validated:
PromptVersion,ReasoningandModelUsedare stored as given. The entity guards the numeric range, not the provenance strings; those come from the scoring service and are recorded, not judged. - No domain events are raised anywhere in this file: there is no
AddDomainEventcall, because no other module reacts to a score change.
- Eleven
- Why it's built this way: range validation belongs to the domain because it is a statement about what a score is, not about who asked for one. Keeping the check in a private helper shared by the two public entry points means a future range change cannot be applied to one path and forgotten on the other.
- Where it's used: created by the scoring handler (
ScoreEventSessionsHandler,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsHandler.cs:79, over the repository resolved at:28, passingaiScoringService.ModelIdandaiScoringService.PromptVersionas the provenance pair at:83), read back by the organizer dashboard (GetSessionSelectionDashboardHandler,.../GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:98,338,363), configured bySessionAiScoreConfigurationin Infrastructure, projected asSessionAiScoreDTO, and rendered by the organizer pageSessionSelectionAiScores. - Caveats / not-in-source:
Updateis not on the re-scoring path today. The handler replaces a session's score with a delete-then-add pair inside the same step that writes the new one (ScoreEventSessionsHandler.cs:105-107), a choice its comment justifies by partial-failure behavior: N sequential paid model calls follow, so a run that dies partway through has replaced only what it actually re-scored, and the unique filtered index onSessionIdkeeps at most one live row either way (ScoreEventSessionsHandler.cs:94-103).Updatetherefore remains a valid domain operation with no current caller inSource.
[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).
SessionInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:13· Level 6 · class (static)
What it is: the invariant-rule library for the
Sessionaggregate and its children: title validity, optional-text max lengths, answer-value validity, a not-a-service-session guard (BR-91), status eligibility for engagement actions (BR-49), and an end-after-start check (BR-122). It also owns the reserved manual id range for sessions that never came from Sessionize.Depends on:
CommonInvariants(SessionInvariants.cs:2),Result/Error(:3),SessionStatuses(same namespace), andSessionDTOfromMMCA.ADC.Conference.Shared.Sessions(:1).Concept: the static-invariant-class pattern (methods returning
Result, composed withResult.Combine) was introduced for the framework inCommonInvariants.[Rubric §4, Domain-Driven Design]: the rules live in the domain, not in handlers, validators, or the database.The interesting detail here is which direction the constants flow.
[Rubric §15, Best Practices & Code Quality](assesses whether a single change stays a single edit). Every length constant on this class is an alias for the matching constant onSessionDTO:public const int TitleMaxLength = SessionDTO.TitleMaxLength;(SessionInvariants.cs:16). The class doc comment (:7-12) explains the direction: the numbers live on the DTO, "the lowest layer the UI can also reach, so markup and domain validation cannot drift apart". A BlazorMaxLengthattribute, an EFHasMaxLength(...)call, and a domain length check therefore all resolve to one literal. Domain does not depend on Application here;Conference.Sharedsits beneath both, so[Rubric §3, Clean Architecture]is preserved rather than bent.Walkthrough
- Length constants (
SessionInvariants.cs:16-37):TitleMaxLength(:16),DescriptionMaxLength(:19),StatusMaxLength(:22),AccessibilityInfoMaxLength(:25),ResourceLinksMaxLength(:28),LiveUrlMaxLength(:31), andRecordingUrlMaxLength(:34) all forward to theirSessionDTOcounterparts, whose literals are 500, 4000, 100, 500, 2000, 2000, and 2000 respectively (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:18-36).AnswerValueMaxLengthis the one literal declared here, 4000 (SessionInvariants.cs:37), because the answer value is a child-entity field with no DTO-level counterpart onSessionDTO. - Reserved id range (
SessionInvariants.cs:44,47):ManualIdRangeStart=999_999_000andManualIdRangeEnd=999_999_999, bothstatic readonly SessionIdentifierType. The doc comment (:39-43) encodes the design decision behind them: session ids are app-assigned because the int PK is the Sessionize id, so ids for sessions never imported from Sessionize (organizer-created and seeded samples) sit above any real Sessionize id and never collide. It mirrorsQuestionInvariants. EnsureTitleIsValid(SessionInvariants.cs:49-52): combines a not-empty and a max-length check fromCommonInvariants, each tagged with a stable error code (Session.Title.Empty,Session.Title.TooLong) and the caller'ssourcestring for tracing.EnsureOptionalTextLengthsAreValid(SessionInvariants.cs:67-81): one call validating description, status, live URL, recording URL, accessibility info, and resource links against their constants. Its doc comment (:54-58) gives both reasons it exists: oversize input should fail as a domain validation error rather than as a database constraint violation, and the URL fields are length-checked only because the values are stored as opaque strings for Sessionize compatibility.EnsureAnswerValueIsValid(SessionInvariants.cs:83-86): the not-empty plus max-length pair forSessionQuestionAnswer.AnswerValue, codedSessionQuestionAnswer.AnswerValue.Empty/.TooLong.EnsureNotServiceSession(SessionInvariants.cs:94-100): delegates toCommonInvariants.EnsureFlagIsFalseand fails withSession.IsServiceSessionwhen the session is a service slot such as lunch or a break (BR-91), which is how bookmarking and feedback are kept off non-content sessions.EnsureStatusIsEligible(SessionInvariants.cs:109-116): delegates toSessionStatuses.IsEligible(:110) and turns a false into aSession.StatusIneligibleerror carrying the offending status in its message. The eligibility allow-list stays in the Level 0 catalog: there is exactly one definition of "eligible".EnsureEndsAtIsAfterStartsAt(SessionInvariants.cs:126-138): the only method with a statement body. Both values must be non-null for the check to run (null means not yet scheduled), andendsAt <= startsAtfails withSession.Duration.Invalid, so a zero-duration session is rejected as firmly as an inverted one (BR-122).
- Length constants (
Why it's built this way: sharing the length constants between the DTO, the EF configuration, and the domain check keeps markup, schema, and rule in lockstep, and expressing each rule as a
Result-returning function makes them composable:Session.Createcombines three of them in oneResult.Combineand reports all failures together.Where it's used:
Session.CreateandSession.Update(Session.cs:183-186and:251-254),SessionQuestionAnswer.Create/UpdateAnswer(SessionQuestionAnswer.cs:52and:73), the application-layer session validators (G18), and the EF entity configurations for column lengths (G19,SessionConfiguration).Caveats / not-in-source:
EnsureNotServiceSessionand the twoManualIdRange*values have no caller inside this file; their consumers are in the Application layer and the seeders, so their call sites are covered in G18 and G19.
Session
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:22· Level 8 · class (sealed)
What it is: the richest aggregate root in the Conference module.
Sessionowns three child collections (SessionSpeaker,SessionCategoryItem,SessionQuestionAnswer) and coordinates their whole lifecycle: creation, update, restore, cascade soft-delete, and a domain event for every structural change. Session ids are Sessionize-assigned, not database-generated.Depends on:
AuditableAggregateRootEntity<TIdentifierType>bound toSessionIdentifierType,IAuditedEntity,DomainEntityState,NavigationAttribute,EntityTypeExtensions(theIsIdValueGeneratedextension),Result/Error; the sibling entitiesEventandRoomas reference navigations;SessionInvariants; its three children and their domain eventsSessionChanged,SessionSpeakerChanged,SessionCategoryItemChanged,SessionQuestionAnswerChanged.Concept introduced, the aggregate root as consistency boundary.
[Rubric §4, Domain-Driven Design](assesses aggregates with a single transactional boundary and correct child lifecycle management). An aggregate root is the only entry point for mutations inside its boundary: nothing outsideSessionconstructs or removes aSessionSpeaker, every such operation goes throughSession.AddSessionSpeaker/RemoveSessionSpeaker. Three guarantees follow at once:- Cross-child invariants have a home.
AddSessionSpeakerrejects a duplicate live speaker (Session.cs:319-326) andAddSessionCategoryItemrejects a duplicate live category item (Session.cs:401-408). Neither check could live on the child, which cannot see its siblings. - Event emission is not optional. Every structural change raises a domain event, making the change observable to other modules through the outbox (ADR-003) without the aggregate knowing who listens.
[Rubric §6, CQRS & Event-Driven]. - Cascade soft-delete is domain behavior.
Delete()(Session.cs:283) soft-deletes every active child before raisingSessionChanged(Deleted), implementing BR-55 in the model rather than through a database cascade or handler glue.
The private constructors (
Session.cs:113and:115) plus thestatic Result<Session> Createfactory (Session.cs:165) are what make that boundary real: there is no way to obtain aSessionthat skipped validation or the creation event.A second concept lands here too: the change trail. The class is marked
IAuditedEntity(Session.cs:22), and the class doc comment (Session.cs:16-20) gives the reason in business terms: sessions are written by organizers and overwritten by the Sessionize sync, so "what changed this title, room, or time slot, and was it a person or the importer" is a question that actually gets asked, and only a change history answers it.[Rubric §13, Observability & Operability]: audit stamps say who touched the row last, the trail says what the sequence was.A third: the framework owns the child-collection mechanics.
[Rubric §1, SOLID]and[Rubric §15, Best Practices & Code Quality]. Delete, restore, and remove-by-id are not hand-rolled loops here; they call fourprotected statichelpers on the base class,DeleteChildren,RestoreChild,RemoveChildOrNotFound, andGetChildOrNotFound(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:273,:212,:156,:103). The split is deliberate and documented at the helper: the helper owns the mechanics, and the aggregate method owns the meaning, so the caller still decides which domain event to raise and what it carries (AuditableAggregateRootEntity.cs:129-145).- Cross-child invariants have a home.
Walkthrough
- Scalar properties (
Session.cs:25-67): fifteenprivate setfields.Titleis the only non-nullable text (:25);Statusis free text imported from Sessionize (:37); the booleansIsInformed/IsConfirmed(:40,43) track the speaker-communication workflow, andIsServiceSession/IsPlenumSession(:46,49) classify the slot.LiveUrlandRecordingUrl(:52,55) arestring?, notUri, for Sessionize compatibility.EventId(:64) andRoomId(:67) are scalar FKs, the latter nullable because a session may not have a room yet. - Reference navigations (
Session.cs:70-75):Event?andRoom?are[Navigation]-tagged with aprivate set, mutated only through the publicSetEvent(:301) andSetRoom(:305) methods the navigation populator calls (G11). TheEventdoc comment notes it exists for query filtering (BR-132). Keeping the setter private and exposing a named method means an accidental assignment from a handler cannot happen by property syntax alone. Duration(Session.cs:80-82): a computedint?in minutes derived fromStartsAt/EndsAt, with no backing column.- Child collections (
Session.cs:84-110): threeprivate readonly List<T>fields exposed asIReadOnlyCollection<T>through.AsReadOnly().SessionSpeakers(:88) andSessionCategoryItems(:110) carry[Navigation(IsCollection = true)].SessionQuestionAnswers(:104) deliberately does not, and its remarks (:95-103) are worth reading in full: the collection grows with attendance rather than with the schedule, it was riding along on the hottest public reads (the session grid, session detail, the speaker dashboard) which never render it, and it is the one child collection here that is not public data, since its dedicated controller is authenticated and scopes rows per caller whileGET /sessions?includeChildren=trueis anonymous. Handlers that genuinely need the answers pass an explicitincludes:list.[Rubric §12, Performance & Scalability]and[Rubric §11, Security]in one attribute that is absent. Create(Session.cs:165-215): combinesEnsureTitleIsValid,EnsureEndsAtIsAfterStartsAt, andEnsureOptionalTextLengthsAreValid(:183-186) so all validation failures surface together, then readstypeof(Session).IsIdValueGenerated(:190).Sessioncarries no[IdValueGenerated]attribute, so that is false and the factory assignsid!.Value(:207); the identical line in a database-generated entity leavesdefault. It ends by raisingSessionChanged(Added)(:212).Update(Session.cs:235-276): the same three-invariant combine (:251-254), then assigns every mutable field including the two workflow booleans andRoomId(:258-271), and raisesSessionChanged(Updated)(:273).Delete(Session.cs:283-297): oneResult.Combineover threeDeleteChildren<TChild, TChildId>calls andbase.Delete()(:287-291), thenSessionChanged(Deleted)only when the whole combine succeeded (:293-294). The comment above it (:285-286) records why combine rather than short-circuit: aggregating every child failure with the root's own means a failing child cannot leave earlier children and the root already flagged. The helper itself skips children that are already deleted (AuditableAggregateRootEntity.cs:283-286), which makes re-deleting a parent idempotent with respect to its children.- Child mutation methods: speakers at
Session.cs:315(AddSessionSpeaker),:352(RestoreSessionSpeaker),:371(RemoveSessionSpeaker); category items at:397,:435,:457; question answers at:484(AddSessionQuestionAnswer),:508(UpdateSessionQuestionAnswer),:531(RemoveSessionQuestionAnswer). Each delegates to the child's ownCreate/UpdateAnsweror to a base helper, mutates the private list, and raises the child-specific*Changedevent. - The restore path (
Session.cs:352-364and:435-450) is the interesting one. It takes the join instance rather than an id, because a soft-deleted row is excluded by the global query filter and so must be resolved by the caller (remarks at:345-349). It hands the instance toRestoreChild<...>along with the aggregate's own error code,"Session.Speaker.NotDeleted"(:356-357) or"Session.CategoryItem.NotDeleted"(:439-443); the helper refuses a not-deleted candidate (AuditableAggregateRootEntity.cs:223-231), calls the child'sReactivate(), and re-adds it to the list only if absent (:243-246). The aggregate then raisesSessionSpeakerChanged(Added)because the association re-enters the visible set (Session.cs:361). BR-135: an association that reappears in the Sessionize feed is reactivated rather than duplicated by a second row. - Removal (
Session.cs:371-382,:457-468,:531-542): each callsRemoveChildOrNotFound<TChild, TChildId>and, on success, raises the matching*Changed(Deleted)event with the removed child's id. A missing or already-deleted id yields aNotFoundfailure from the helper rather than a null reference. - Lookup helper (
Session.cs:550-553): a single privateGetSessionQuestionAnswerOrNotFoundrouting through the baseGetChildOrNotFound<TChild, TChildId>, used only byUpdateSessionQuestionAnswer(:512). The other two children have no update path, so they need no lookup wrapper. SetSession*methods (Session.cs:386,:472,:546):internal, used only by the navigation populator. They call the baseSetItemshelper (AuditableAggregateRootEntity.cs:60) to replace in-memory collections during query-side population, bypassing domain logic. They are never on the command path.
- Scalar properties (
Why it's built this way: the aggregate boundary makes atomicity natural, one
SaveChangesAsynccommits the session and all of its children together, and domain events raised inside the same transaction reach other modules through the outbox without the aggregate knowing they exist.[Rubric §29, Resilience & Business Continuity]: cascade soft-delete keeps children from surviving in a live-but-unreachable state after their parent is gone, the policy recorded in ADR-005.Where it's used: the central Conference entity. Persisted through
SessionConfigurationand the repositories (G19), read asSessionDTOthrough the query services, and mutated by the Session command handlers (G18); its ids and eligibility rules are consumed cross-service by Engagement through the bookmark and live-validation contracts.
SessionCategoryItem
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionCategoryItem.cs:14· Level 8 · class (sealed)
- What it is: the join entity linking a
Sessionto aCategoryItem, with database-generated identity ([IdValueGenerated],SessionCategoryItem.cs:13). A child of theSessionaggregate. - Depends on:
AuditableBaseEntity<TIdentifierType>bound toSessionCategoryItemIdentifierType,IReactivatable,IdValueGeneratedAttribute,EntityTypeExtensions,NavigationAttribute,Result, andSessionas the back-reference. - Concept introduced, the join entity.
[Rubric §4, DDD]: rather than letting EF create a raw join table, a many-to-many association is modeled as a domain entity, so it carries its own identity, audit fields, and soft-delete state, and participates in domain events through the owning aggregate. All threeSessionchildren share this shape and differ only in the FK they carry, whether they hold a payload, and whether they can be reactivated. - Walkthrough:
CategoryItemId(SessionCategoryItem.cs:17,private set); theSession?back-navigation (:20-21,[Navigation]with aprivate set, assigned through the publicSetSessionat:61);SessionId(:24), get-only because EF sets it as the shadow-side FK when the child is added to the parent's list. Two constructors: the EF parameterless one (:27) and a private assigning one (:29).Create(id?, categoryItemId)(:37-49) readstypeof(SessionCategoryItem).IsIdValueGenerated(:41), which is true here, soIdstaysdefaultfor the database to fill (:45); it performs no validation, because the association is structurally always valid, and therefore always returnsResult.Success(:48).Reactivate()(:57) is theIReactivatableimplementation and simply exposes the protected baseUndelete()(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:89) to the framework'sRestoreChildhelper; its doc comment (:51-56) explains why the join is reactivated rather than re-created when an association reappears in the Sessionize feed (BR-135). - Where it's used: managed exclusively through
Session.AddSessionCategoryItem/RestoreSessionCategoryItem/RemoveSessionCategoryItem, and projected asSessionCategoryItemDTO.
SessionQuestionAnswer
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionQuestionAnswer.cs:13· Level 8 · class (sealed)
- What it is: a child entity of
Sessionstoring the answer to aQuestionfor that session. Database-generated identity ([IdValueGenerated],SessionQuestionAnswer.cs:12). - Depends on:
AuditableBaseEntity<TIdentifierType>bound toSessionQuestionAnswerIdentifierType,IdValueGeneratedAttribute,EntityTypeExtensions,NavigationAttribute,Result,Session, andSessionInvariants. - Concept: the same join-entity shape as
SessionCategoryItem, with two differences that matter. It carries a validated payload, so unlike its two siblings itsCreatecan fail; and it does not implementIReactivatable(contrastSessionQuestionAnswer.cs:13withSessionCategoryItem.cs:14), so it can never be passed to the framework'sRestoreChildhelper. That absence is the design statement: an answer is content a person wrote, not a Sessionize-fed association that may reappear, so a deleted answer is re-created rather than resurrected.[Rubric §4, DDD]. - Walkthrough:
QuestionId(SessionQuestionAnswer.cs:16) andAnswerValue(:19), theSession?navigation (:22-23, set through the publicSetSessionat:84), and the get-onlySessionId(:26). The EF constructor seedsAnswerValuetostring.Empty(:29); the private assigning constructor is at:31-37.Create(id?, questionId, answerValue)(:46-64) validates throughSessionInvariants.EnsureAnswerValueIsValid(:52) before touching identity, then applies the sameIsIdValueGeneratedbranch as its siblings (:56,60).UpdateAnswer(answerValue)(:71-80) re-runs the identical invariant (:73) and mutates the field (:77), so an update cannot bypass a rule that creation enforced. - Where it's used: managed through
Session.AddSessionQuestionAnswer/UpdateSessionQuestionAnswer/RemoveSessionQuestionAnswer, and exposed asSessionQuestionAnswerDTOthrough a dedicated authenticated controller (G20).
SessionSpeaker
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionSpeaker.cs:14· Level 8 · class (sealed)
- What it is: the join entity linking a
Sessionto aSpeaker, with database-generated identity ([IdValueGenerated],SessionSpeaker.cs:13). - Depends on:
AuditableBaseEntity<TIdentifierType>bound toSessionSpeakerIdentifierType,IReactivatable,IdValueGeneratedAttribute,EntityTypeExtensions,NavigationAttribute,Result, andSession. - Concept: the same join-entity pattern as
SessionCategoryItem, and the thinnest of the three (a singleSpeakerIdpayload). Its value as a teaching example is where the uniqueness rule is not: the duplicate-speaker invariant lives inSession.AddSessionSpeaker(Session.cs:319-326), not here. A rule that spans a collection belongs to the aggregate root that owns the collection, because the child can only see itself.[Rubric §4, DDD]. - Walkthrough:
SpeakerId(SessionSpeaker.cs:17), theSession?navigation (:20-21, assigned through the publicSetSessionat:61), the get-onlySessionId(:24), the EF and assigning constructors (:27,29).Create(id?, speakerId)(:37-49) only resolves identity throughIsIdValueGenerated(:41,45) and always succeeds.Reactivate()(:57) forwards to the protected baseUndelete()(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:89); its doc comment (:51-56) records the BR-135 rationale, that the row carries the Sessionize-assigned speaker id, so a returning association is reactivated rather than duplicated. - Where it's used: managed through
Session.AddSessionSpeaker/RestoreSessionSpeaker/RemoveSessionSpeaker; its projectionSessionSpeakerDTOis what the public session grid renders for speaker names.
EventLiveInfo
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events.Live·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/EventLiveInfo.cs:13· Level 0 · record (sealed)
- What it is: an immutable three-field snapshot of one event's live-window facts: whether the event is published, and the UTC start and (exclusive) end of its live window. It is what the Engagement live layer reads to decide "is this conference happening right now" without ever touching Conference's
Eventaggregate. - Depends on: nothing first-party (BCL
bool/DateTimeonly). - Concept introduced, the cross-module live-window snapshot.
[Rubric §7, Microservices Readiness](assesses whether a module exposes a small, stable contract instead of leaking its internal entities across a boundary): rather than shipping the wholeEvententity to another module (or, after extraction, another process), Conference does the time-zone arithmetic once, server-side, and hands back three plain values. The consumer then compares them againstDateTime.UtcNowwith no time-zone logic of its own (doc comment,EventLiveInfo.cs:3-9). The window is derived from the event'sStartDate/EndDateand its IANA time zone: start isStartDateat 00:00 local, end (exclusive) isEndDate + 1 dayat 00:00 local, both converted to UTC.[Rubric §9, API & Contract Design](a narrow, purpose-built contract) also applies: this record carries exactly what a consumer needs to gate a live feature, nothing more. - Walkthrough: a positional
sealed record(EventLiveInfo.cs:13) with three parameters,IsPublished(bool),LiveWindowStartUtcandLiveWindowEndUtc(bothDateTime, the end being exclusive per the param docs onEventLiveInfo.cs:10-12). There is no behavior: the type is a pure value carrier, and being arecordit gets structural equality for free. - Why it's built this way: keeping the "when is an event live" definition in the owning module and putting only UTC instants on the wire means the rule lives in exactly one place, and the contract itself is time-zone-free. Consumers cannot drift from the canonical window because they never recompute it.
- Where it's used: produced by the real
EventLiveValidationService(Conference.Application) behindIEventLiveValidationService.GetEventLiveInfoAsync(EventLiveValidationService.cs:27, built at:44), and across the process boundary by theEventLiveValidationServiceGrpcAdapter(EventLiveValidationServiceGrpcAdapter.cs:37-63). The fail-open stubDisabledEventLiveValidationServicereturnsnew EventLiveInfo(true, DateTime.MinValue, DateTime.MaxValue)(DisabledEventLiveValidationService.cs:27). It is consumed by the Engagement event-wideLivePollpaths:CreateLivePollHandlerreadsIsPublishedto enforce BR-222 (CreateLivePollHandler.cs:69-84), andOpenLivePollHandlerreads the two window bounds for an event-wide poll (OpenLivePollHandler.cs:67-72). The Engagement check-in path uses it too:CheckInProcessorfetches it for an event-scope check-in and rejects an unpublished event (CheckInProcessor.cs:180-184).
QuestionModerationDefault
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events.Live·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/QuestionModerationDefault.cs:7· Level 0 · enum
- What it is: a two-value enum naming the initial status a newly submitted attendee question receives for an event's live Q&A (BR-233):
Pending(queued for a moderator) orApproved(visible immediately, moderated after the fact). - Depends on: nothing first-party.
- Concept introduced, the per-event moderation policy knob.
[Rubric §6, CQRS & Event-Driven](assesses whether the data crossing a boundary carries enough context to be acted on without extra lookups): this small enum is the vocabulary the Engagement live layer reads to decide whether a freshly submittedSessionQuestionstarts hidden or visible. Making it a two-value enum rather than a bareboolleaves room for future moderation modes and reads self-documentingly at the call site. - Walkthrough: two explicitly numbered members,
Pending = 0(QuestionModerationDefault.cs:10, the safe default: an unset or zero value means "hold for review") andApproved = 1(QuestionModerationDefault.cs:13). Explicit numbering keeps the wire meaning stable if the members are ever reordered. - Why it's built this way:
Pending = 0makes the conservative choice the default value. An event that never set a moderation preference holds new questions for review rather than publishing them unmoderated. - Where it's used: carried on
EventDTO.QuestionModerationDefault(EventDTO.cs:94) and insideSessionLiveInfo(SessionLiveInfo.cs:24), so the live layer learns the owning event's policy in the same call that fetches session facts. The stubDisabledEventLiveValidationServicereportsPending(DisabledEventLiveValidationService.cs:43). Consumed by the EngagementSubmitQuestionHandler, which mapsApprovedtoQuestionStatus.Approvedand everything else toQuestionStatus.Pendingwhen creating the question (SubmitQuestionHandler.cs:86-88).
RefreshFromSessionizeResultDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RefreshFromSessionizeResultDTO.cs:7· Level 0 · record (sealed)
- What it is: the response DTO for the Sessionize refresh endpoint (
POST /Events/{id}/refresh, UC-6). It reports per-entity sync counts plus a list of non-fatal warnings, so an organizer can confirm what was actually imported. - Depends on: nothing first-party (BCL only).
- Concept introduced, the informative mutation response.
[Rubric §9, API & Contract Design](assesses stable, useful response contracts): rather than returning204 No Contentfor a bulk sync, the endpoint returns counts so the caller can verify that the expected number of sessions, speakers, and categories landed. This is the read-back shape of a bulk write.[Rubric §13, Observability & Operability]also applies in the small: theWarningslist turns silent partial-import oddities into something an operator can read off the response. - Walkthrough: eight
required initproperties (RefreshFromSessionizeResultDTO.cs:10-31). Six areintcounts,CategoriesSynced(line 10),CategoryItemsSynced(line 13),RoomsSynced(line 16),QuestionsSynced(line 19),SpeakersSynced(line 22), andSessionsSynced(line 25).SkippedSoftDeleted(line 28) counts entities that a sync re-encountered but did not restore because the app had soft-deleted them (BR-136).Warnings(line 31) is anIReadOnlyList<string>of non-fatal issues such as a duration violation or a date-range mismatch. Every property isrequired, so a partial or forgotten field cannot be constructed. - Why it's built this way:
SkippedSoftDeletedis surfaced explicitly because an organizer who soft-deleted a session and then re-ran a sync would otherwise be puzzled why the count does not match Sessionize. The handler even folds that count into the warning list when it is non-zero (RefreshFromSessionizeHandler.cs:129-132). - Where it's used: built by the
RefreshFromSessionizeCommandhandler, which reads the per-strategySessionizeSyncResultvalues and the shared sync context into it (RefreshFromSessionizeHandler.cs:144-154), with an all-zero instance returned when Sessionize sends an empty response (RefreshFromSessionizeHandler.cs:101-111). Returned byEventsController.RefreshAsync(EventsController.cs:329, whose[Idempotent]attribute replays the first response for a retriedIdempotency-Keyrather than starting a second import,EventsController.cs:328) and surfaced to the organizer UI throughEventService.RefreshFromSessionizeAsync(EventService.cs:43-51), which theEventDetailpage holds as_refreshResult(EventDetail.razor.cs:63, assigned at:254).
RoomSessionInfo
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Rooms·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Rooms/RoomSessionInfo.cs:20· Level 0 · record (sealed)
- What it is: the answer to "which session is this room hosting right now": the resolved session id and title, the owning event, and that event's published flag. It is what the Engagement room check-in flow gets back when an attendee scans a printed room QR.
- Depends on: the
SessionIdentifierTypeandEventIdentifierTypealiases; BCL otherwise. No first-party types. - Concept introduced, the server-resolved target (never trust a client-supplied id).
[Rubric §11, Security](assesses whether authorization-relevant inputs are decided server-side): the attendee's device sends a room id, not a session id, and Conference resolves which session that room is hosting at the call instant. A tampered QR therefore cannot record a check-in against an arbitrary session.[Rubric §9, API & Contract Design]also applies in a subtle way:SessionTitlerides along so the caller can render a confirmation without a second cross-module call, and the grace window travels in the request rather than living on this record, because "how early does a scan count" is check-in policy owned by Engagement, not schedule data owned by Conference (doc comment,RoomSessionInfo.cs:10-14, and the same split restated on the interface,IEventLiveValidationService.cs:52-55). - Walkthrough: a positional
sealed recordwith four parameters (RoomSessionInfo.cs:20-24),SessionId(the session the room is hosting at the query instant, line 19),SessionTitle(line 20),EventId(the owning event, line 21), andIsPublished(line 22). No behavior. - Why it's built this way: bundling the title and the published flag with the id keeps the door-scan path to a single cross-module round-trip, and keeping the grace window out of the record preserves the boundary: Conference answers the schedule question, Engagement decides the policy.
- Where it's used: produced by
EventLiveValidationService.GetCurrentRoomSessionInfoAsync(EventLiveValidationService.cs:145), which excludes unscheduled sessions (EventLiveValidationService.cs:157-167), resolves the event's zone without a fallback (EventLiveValidationService.cs:182-186), converts session wall-clock times throughCalendarExportMapper.ToUtc(EventLiveValidationService.cs:191-199), and prefers an in-progress session over an upcoming one inside the grace window (EventLiveValidationService.cs:203-210) before building the record (EventLiveValidationService.cs:218-222). Carried over the wire byEventLiveValidationServiceGrpcAdapter(EventLiveValidationServiceGrpcAdapter.cs:128) against theGetCurrentRoomSessionInforpc (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/Protos/event_live_validation.proto:47). Consumed by the EngagementRecordRoomCheckInHandler, which passes the configured grace window (RecordRoomCheckInHandler.cs:52-54, fromCheckInSettings.RoomCheckInGraceMinutes, default 15,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/CheckIns/CheckInSettings.cs:21), collapses aNotFoundinto a generic "no session is starting here" message so room ids do not leak while still propagating transport failures unchanged (RecordRoomCheckInHandler.cs:55-65,:98-102), rejects an unpublished event (RecordRoomCheckInHandler.cs:68-69), and uses the resolvedSessionId/SessionTitlefor the check-in row and its response (RecordRoomCheckInHandler.cs:73,:91-92).
SponsorLiveInfo
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events.Live·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/SponsorLiveInfo.cs:12· Level 0 · record (sealed)
- What it is: a sponsor's live-layer facts for the booth-visit flow: the owning event's id and published flag, plus the sponsor's display name.
- Depends on: the
EventIdentifierTypealias; BCL otherwise. No first-party types. - Concept: the same server-resolved cross-module snapshot
RoomSessionInfointroduces, applied to aSponsor.[Rubric §7, Microservices Readiness]: Engagement never references theSponsorentity; it asks Conference three questions and gets three values.[Rubric §11, Security]: because the QR is printed, the server must confirm the sponsor still exists and its event is published before recording anything, and the soft-delete query filter means a pulled sponsor answers exactly like one that never existed, so an old printed QR simply stops working (EventLiveValidationService.cs:110-123). - Walkthrough: a positional
sealed recordwith three parameters (SponsorLiveInfo.cs:12-15),EventId(line 13),IsPublished(line 14), andSponsorName(line 15, carried so a consumer can render a confirmation without a second cross-module call, per the doc commentSponsorLiveInfo.cs:3-8). - Why it's built this way: the booth-visit write needs the owning event for scoping, the published flag for the gate, and the name for the confirmation screen. Returning all three in one record keeps the scan path to a single round-trip.
- Where it's used: produced by
EventLiveValidationService.GetSponsorLiveInfoAsync(EventLiveValidationService.cs:106-141, which looks the sponsor up, then its owning event, and returnsNotFoundfor either miss), served over gRPC via theGetSponsorLiveInforpc (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/Protos/event_live_validation.proto:40) and its adapter (EventLiveValidationServiceGrpcAdapter.cs:99). Consumed by the EngagementRecordSponsorVisitHandler, which propagates a lookup failure unchanged (RecordSponsorVisitHandler.cs:61-62), rejects an unpublished event (RecordSponsorVisitHandler.cs:65-66), scopes the check-in row to the returnedEventId(RecordSponsorVisitHandler.cs:77), and echoesSponsorNameon the response whether the visit is new or a replay (RecordSponsorVisitHandler.cs:92). The fail-open stub returnsnew SponsorLiveInfo(default, true, string.Empty)(DisabledEventLiveValidationService.cs:51).
EventQuestionAnswerDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventQuestionAnswerDTO.cs:9· Level 1 · record (class)
- What it is: the read/write DTO for one event-level question answer, linking an event to a metadata question with the answer text a speaker or organizer supplied. It is one of the three child-collection DTOs that
EventDTOcomposes. - Depends on:
IBaseDTO<TIdentifierType>(EventQuestionAnswerDTO.cs:1,9), closed overEventQuestionAnswerIdentifierType; the foreign-key fields use theEventIdentifierTypeandQuestionIdentifierTypealiases. - Concept introduced, the child-collection DTO. The DTO shape and the
IBaseDTO<TIdentifierType>contract were taught in group-12; this is the family of child DTOs that an aggregate DTO composes.[Rubric §9, API & Contract Design](DTOs decoupled from domain entities, stable contracts): this record is the wire shape clients see, and theEventQuestionAnswerjoin entity never crosses the boundary. Cross-aggregate references appear as scalar foreign keys (QuestionId), never nested objects, consistent with database-per-service (ADR-006), where the related aggregate may live in a different database. - Walkthrough: four
required initproperties (EventQuestionAnswerDTO.cs:12-21),Id(the strong id alias, line 12),EventId(foreign key to the parent event, line 15),QuestionId(foreign key to the question, line 18), andAnswerValue(the answer text, line 21). Being arecord classwith all-requiredmembers, it cannot be partially constructed and is immutable after creation. - Why it's built this way: modelling the join as a flat DTO with scalar foreign keys keeps the contract stable and portable across a process boundary, and it is the same shape the sibling child DTOs use.
- Where it's used: nested in
EventDTO.EventQuestionAnswers(EventDTO.cs:109); mapped from theEventQuestionAnswerentity byEventQuestionAnswerDTOMapper(group-18).
EventSpeakerDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventSpeakerDTO.cs:8· Level 1 · record (class)
- What it is: the thinnest of the event child DTOs, the many-to-many join row between an event and a speaker.
- Depends on:
IBaseDTO<TIdentifierType>(EventSpeakerDTO.cs:1,8) closed overEventSpeakerIdentifierType; the foreign keys useEventIdentifierTypeandSpeakerIdentifierType. - Concept: the same child-collection DTO shape introduced on
EventQuestionAnswerDTO, a flatrecord classwithrequired initmembers and scalar foreign keys.[Rubric §9, API & Contract Design]. - Walkthrough: three
required initproperties (EventSpeakerDTO.cs:11-17),Id(line 11),EventId(foreign key to the parent event, line 14), andSpeakerId(foreign key to the speaker, line 17). Nothing else: this row exists only to associate anEventwith aSpeaker. - Where it's used: nested in
EventDTO.EventSpeakers(EventDTO.cs:106); mapped from theEventSpeakerentity byEventSpeakerDTOMapper(group-18).
RoomDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Rooms·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Rooms/RoomDTO.cs:13· Level 1 · record (class)
- What it is: the richest of the event child DTOs, a conference room within an event's venue, with display and accessibility metadata. It also declares the four room field-length caps that the rest of the stack reads.
- Depends on:
IBaseDTO<TIdentifierType>(RoomDTO.cs:1,13) closed overRoomIdentifierType; the foreign key usesEventIdentifierType. - Concept introduced, the DTO as the lowest common home for a shared constant. The child-DTO shape itself is the one
EventQuestionAnswerDTOintroduces; what is new here is that the DTO owns the length constants.[Rubric §15, Best Practices & Code Quality](assesses whether a fact is written once): the domain invariants sit in Conference.Domain, the EF configuration in Conference.Infrastructure, and the form model in Conference.UI, and none of those three can reference the other two. The*.Sharedproject is the only assembly all of them already depend on, so the caps live on the DTO and everyone re-exports rather than re-types them (doc comment,RoomDTO.cs:6-11).[Rubric §21, Accessibility]is worth naming too, because accessibility data is modelled as first-class room data (AccessibilityInfo,RoomDTO.cs:46) rather than being buried in a free-text description. - Walkthrough
- Length constants (
RoomDTO.cs:16-25):NameMaxLength = 255(line 16),FloorMaxLength = 100(line 19),LocationMaxLength = 255(line 22),AccessibilityInfoMaxLength = 500(line 25). - Three
requiredmembers,Id(RoomDTO.cs:28),Name(RoomDTO.cs:31), andEventId(RoomDTO.cs:49, the parent foreign key, declared last in the file). Sort(RoomDTO.cs:34) is a plainintdisplay order that defaults to zero. Four optional members follow,Capacity(int?, line 37),Floor(string?, line 40),Location(string?, line 43), andAccessibilityInfo(string?, line 46), each null when absent.
- Length constants (
- Why it's built this way: a room imported from Sessionize often has nothing beyond a name and a sort order, so everything past those is nullable. Making
EventIdrequired keeps a room from existing on the wire without an owning event. The constants live here so the same number reaches the database column, the domain guard, and the input counter from one declaration. - Where it's used: nested in
EventDTO.Rooms(EventDTO.cs:103); mapped from theRoomentity byRoomDTOMapper(group-18). The constants are re-exported byEventInvariantsasRoomNameMaxLength,RoomFloorMaxLength,RoomLocationMaxLength, andRoomAccessibilityInfoMaxLength(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:46-56), which the guard itself uses (EventInvariants.cs:138) and whichRoomConfigurationturns into EFHasMaxLengthcalls (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Events/RoomConfiguration.cs:20,:30); the UI reads them straight off the DTO inRoomFormModel(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Rooms/RoomFormModel.cs:35,:45,:49,:53).
SessionLiveInfo
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events.Live·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/SessionLiveInfo.cs:17· Level 1 · record (sealed)
- What it is: the session-level counterpart to
EventLiveInfo, a snapshot of everything the live layer needs to gate a single session's polls and Q&A: the owning event's id, published flag and live window, the session's assigned speaker ids, whether the session is a plenum (whole-conference) session, and the event's question-moderation default. - Depends on:
QuestionModerationDefault(a parameter,SessionLiveInfo.cs:24); theEventIdentifierTypeandSpeakerIdentifierTypealiases. BCL otherwise. - Concept introduced, the enriched cross-module session snapshot.
[Rubric §7, Microservices Readiness](a single, sufficient contract crossing the boundary): the Engagement live layer must answer several questions before it lets someone open a poll or moderate a question. Is the event published and live? Who are the session's speakers, so it can grant them moderation rights (BR-236)? Is it a plenum session? What is the default status for new questions (BR-233)? Rather than force several separate cross-service calls, Conference bundles all of it into one record returned byGetSessionLiveInfoAsync.[Rubric §12, Performance & Scalability](one round-trip instead of many) is the payoff of that bundling. - Walkthrough: a positional
sealed recordwith seven parameters (SessionLiveInfo.cs:17-24),EventId(the owning event, line 18),IsPublished(line 19),LiveWindowStartUtcandLiveWindowEndUtc(lines 20-21, same live-window semantics asEventLiveInfo),SpeakerIds(IReadOnlyCollection<SpeakerIdentifierType>, the session's non-deleted assigned speakers, line 22),IsPlenumSession(line 23), andQuestionModerationDefault(line 24). No behavior: a pure value carrier. - Why it's built this way: the producer already loads the session and its owning event to compute the window, so it enriches the same result with the speaker set, plenum flag, and moderation default instead of making the consumer chase those separately. That keeps the speaker-rights and moderation decisions on data the owning module vouches for.
- Where it's used: produced by
EventLiveValidationService.GetSessionLiveInfoAsync(EventLiveValidationService.cs:50, built at:93, and it also enforces the eligibility rules BR-49/BR-91) and by its gRPC adapter (EventLiveValidationServiceGrpcAdapter.cs:66). Consumed by every Engagement live-layer entry point:CreateLivePollHandler(CreateLivePollHandler.cs:38-59, including the "session belongs to this event" check that is deliberately skipped whenEventIdisdefault,CreateLivePollHandler.cs:44-45),OpenLivePollHandler(OpenLivePollHandler.cs:47-58),CloseLivePollHandler(CloseLivePollHandler.cs:43),SubmitQuestionHandler(SubmitQuestionHandler.cs:41, the live-window gate at:57, the moderation default at:86-88),ModerateQuestionHandler(ModerateQuestionHandler.cs:56), andGetModerationQueueHandler(GetModerationQueueHandler.cs:33). The speaker set is whatLivePollAuthorizationchecks the caller against (CreateLivePollHandler.cs:54-55). The Engagement check-in path uses it too, for a session-scope check-in (CheckInProcessor.cs:170-174).
EventDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventDTO.cs:16· Level 2 · record (class)
- What it is: the aggregate DTO for a conference event, the full wire shape a client reads or writes for the
Eventaggregate. It composes the three child collections (rooms, speaker associations, question answers) alongside the event's own scalar fields, its concurrency token, and the ten field-length constants the rest of the stack reads. - Depends on:
IBaseDTO<TIdentifierType>andIConcurrencyAware(both implemented,EventDTO.cs:3,16); composesRoomDTO,EventSpeakerDTO, andEventQuestionAnswerDTO; carriesQuestionModerationDefault. - Concept introduced, the aggregate DTO and the optimistic-concurrency round-trip on the wire.
[Rubric §9, API & Contract Design](aggregate DTOs compose child DTOs so a UI gets everything it needs in one call; mapping is manual or Mapperly-generated per ADR-001):EventDTOis the Level-2 composite that bundles the Level-1 children.[Rubric §8, Data Architecture](optimistic concurrency): implementingIConcurrencyAwaremeans the DTO round-trips the EFRowVersiontoken (EventDTO.cs:52), so an update form can detect a concurrent edit instead of silently overwriting one. The body-less publish and unpublish transitions carry the same token in anIf-Matchheader rather than a body, which is why there is no transition request record here:EventsController.PublishAsyncreads it viaSupportsIfMatchAttribute.RequiredTokenand answers a missing header with428and a stale token with412(EventsController.cs:265-277,:295-309). - Walkthrough
- Length constants (
EventDTO.cs:19-46):NameMaxLength = 500(line 17),DescriptionMaxLength = 4000(line 20),TimeZoneMaxLength = 100(line 23),SessionizeCodeMaxLength = 100(line 26),VenueAddressMaxLength = 500(line 29),VenueMapUrlMaxLength = 2000(line 32),WiFiInfoMaxLength = 500(line 35),OrganizerContactEmailMaxLength = 255(line 38),SponsorshipPacketUrlMaxLength = 2000(line 41), andTicketingUrlMaxLength = 2000(line 44). - Identity and concurrency:
Id(required,EventDTO.cs:49) andRowVersion, a non-nullablebyte[]defaulting to[](EventDTO.cs:52), so a freshly constructed DTO carries an empty token rather than a null one. - Required core:
Name(line 53),StartDateandEndDate(bothDateOnly, lines 59 and 62), andTimeZone(line 65, the IANA id used to compute the live window). - Optional scalars:
Description(line 56),SessionizeCode(line 68),VenueAddress(line 71),VenueMapUrl(line 74),WiFiInfo(line 77),OrganizerContactEmail(line 80, the contact published to attendees),SponsorshipPacketUrl(line 83, the published sponsorship packet for this edition), andTicketingUrl(line 86, where attendees buy tickets), allstring?; plusIsPublished(line 89) andQuestionModerationDefault(line 92, BR-233). - Sessionize refresh audit:
LastSessionizeRefreshOn(DateTime?, line 95) andLastSessionizeRefreshBy(string?, line 98), so the UI can show when the last import ran and who ran it. - Child collections (
EventDTO.cs:103-109):Rooms,EventSpeakers, andEventQuestionAnswers, each anIReadOnlyCollection<>of the matching child DTO, each defaulting to an empty collection (= []) so an event with no children is safe to render.
- Length constants (
- Why it's built this way: composing the children inline lets a single
GET /Events/{id}return the whole event graph without follow-up calls, and defaulting the collections to[]avoids null checks in the UI. TheIConcurrencyAwaretoken is the write-path guard that turns a lost update into a conflict instead of a silent overwrite. The constants live on the DTO for the reason its doc comment gives (EventDTO.cs:10-14): it is the lowest layer the domain, EF configuration, and Blazor pages can all reach. - Where it's used: produced by
EventDTOMapper(group-18); it is the DTO type parameter ofEventsControlleritself (EventsController.cs:48-60), so every inherited GetAll/GetById/Create action speaks it, and the same type parameter drives the UI'sEntityServiceBaseinEventService(EventService.cs:15-17). It is the concrete event model thatCurrentEventDefaultsbindsCurrentEventSelectorto. Its constants are re-exported byEventInvariants(EventInvariants.cs:17-44) and bound by the UI'sEventFormModel[MaxLength]attributes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Events/EventFormModel.cs:45-85) and theEventFormFieldsinput counters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Event/EventFormFields.razor:21,:28).
IEventLiveValidationService
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events.Live·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/IEventLiveValidationService.cs:13· Level 3 · interface
- What it is: the cross-module service contract the Engagement live and check-in layers call to validate an event's, a session's, a sponsor's, or a room's live-layer facts, returning small snapshot records without the caller ever referencing a Conference domain entity.
- Depends on:
ResultandServiceContractAttribute(both viaMMCA.Common.Shared.Abstractions,IEventLiveValidationService.cs:2);EventLiveInfo,SessionLiveInfo,SponsorLiveInfo, andRoomSessionInfo; theEventIdentifierType,SessionIdentifierType,SponsorIdentifierType, andRoomIdentifierTypealiases. - Concept introduced, the owned-interface cross-module boundary.
[Rubric §7, Microservices Readiness](assesses boundaries that survive extraction into separate processes) and[Rubric §3, Clean Architecture](a module depends on an interface it can consume, not on another module's internals): the interface lives in Conference's*.Sharedproject, so the module that owns the data publishes the contract, and it is defined in terms of ids and small DTOs only. When both modules run in one host, the real Conference.Application implementation is injected directly; after extraction, the same interface is satisfied by a gRPC adapter. Engagement's code does not change either way. This is the same pattern the module uses forISessionBookmarkValidationService. The[ServiceContract]marker onIEventLiveValidationService.cs:12opts the type into the architecture-fitness rules that guard the contract surface (ADR-015). - Walkthrough: four methods, all returning
Task<Result<...>>with a trailingCancellationToken.GetEventLiveInfoAsync(IEventLiveValidationService.cs:23): returns the event's published flag and live window, or aNotFoundfailure when the event does not exist. Consumers layer their own rules on top (draft creation requires published; opening a poll requires now to be inside the window), as stated in the doc comment (IEventLiveValidationService.cs:15-19).GetSessionLiveInfoAsync(IEventLiveValidationService.cs:34): returns a session's live facts (the owning event's window plus speakers, plenum flag, and moderation default), or a failure when the session does not exist, is a service session (BR-91), or has an ineligible status (BR-49), per the doc comment (IEventLiveValidationService.cs:25-29).GetSponsorLiveInfoAsync(IEventLiveValidationService.cs:45): returns the sponsor's owning event id, that event's published flag, and the sponsor name, or aNotFoundfailure. The doc comment names the caller: the booth-visit flow where an attendee scans a printed deep-link QR and the server must confirm the sponsor exists and belongs to a published event before recording anything (IEventLiveValidationService.cs:36-40).GetCurrentRoomSessionInfoAsync(IEventLiveValidationService.cs:61-64): resolves which session a room is hosting at the call instant "so a consumer never has to trust a client-supplied session id". A session qualifies when the instant falls inside[StartsAt - graceMinutes, EndsAt); an in-progress session wins over an upcoming one, and the earliest upcoming one wins among several (IEventLiveValidationService.cs:48-51).graceMinutesis a parameter, not a Conference setting, because it is check-in policy: Conference only answers the schedule question (IEventLiveValidationService.cs:52-55).
- Why it's built this way: returning
Resultrather than throwing lets the consumer branch onNotFoundand eligibility failures as ordinary control flow. Keeping the contract in*.Shared, expressed in ids and DTOs only, is what makes Conference extractable without breaking Engagement. Passing the grace window in rather than reading it from Conference config keeps policy on the consuming side of the boundary. - Where it's used: implemented in-process by
EventLiveValidationService(Conference.Application, group-18), served over gRPC byEventLiveValidationGrpcServiceand consumed across the boundary viaEventLiveValidationServiceGrpcAdapter. A host wires the remote path withAddConferenceEventLiveValidationClient(), whichReplaces whatever registration is already in the container rather thanTryAdding behind it (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:73-80). Injected into the Engagement live-layer handlers (group-23) and into the check-in handlersRecordRoomCheckInHandler(RecordRoomCheckInHandler.cs:30),RecordSponsorVisitHandler(RecordSponsorVisitHandler.cs:38),ManualCheckInHandler(ManualCheckInHandler.cs:20),CheckInAttendeeHandler(CheckInAttendeeHandler.cs:23), andCheckInProcessor(CheckInProcessor.cs:111,:162). When Conference is not loaded in a host,DisabledEventLiveValidationServicestands in.
DisabledEventLiveValidationService
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events.Live·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/DisabledEventLiveValidationService.cs:23· Level 4 · class (internal sealed)
- What it is: the fail-open stub implementation of
IEventLiveValidationService, registered when the Conference module is not loaded in a host (for example when Engagement runs as its own service without Conference in-process). - Depends on:
IEventLiveValidationService,Result(viaMMCA.Common.Shared.Abstractions,DisabledEventLiveValidationService.cs:2),EventLiveInfo,SessionLiveInfo,SponsorLiveInfo,RoomSessionInfo,QuestionModerationDefault. - Concept introduced, the fail-open disabled-module stub (a Null Object variant).
[Rubric §2, Design Patterns](a Null-Object-style stub keeps consumers running when a dependency is absent) and[Rubric §29, Resilience & Business Continuity](assesses graceful degradation): this stub deliberately fails open. It reports the event as published with an always-open window, so the Engagement live-layer handlers can complete without an in-process Conference module, at the cost of skipping the published and live-window checks (doc comment,DisabledEventLiveValidationService.cs:10-17). Real validation is restored when the host is wired to the Conference gRPC adapter, whichReplaces this stub. It mirrors the convention where each owning module's*.Sharedproject ships aDisabled*Servicestub for the cross-module interfaces it exposes, namingDisabledSessionBookmarkValidationServiceas the precedent (DisabledEventLiveValidationService.cs:18-21). - Walkthrough: four expression-bodied methods, each returning a completed
Taskwrapping a successResult.GetEventLiveInfoAsync(DisabledEventLiveValidationService.cs:26-27):Result.Success(new EventLiveInfo(true, DateTime.MinValue, DateTime.MaxValue)), published, with a window spanning all of time.GetSessionLiveInfoAsync(DisabledEventLiveValidationService.cs:35-43): a successSessionLiveInfowith adefault(unknown) event id, the always-open window, no speakers ([]),IsPlenumSession = false, andQuestionModerationDefault.Pending. The remarks (DisabledEventLiveValidationService.cs:30-34) record the downstream effect: consumers skip the event-match check when the event id isdefault(seeCreateLivePollHandler.cs:44-45), and speaker-based rights resolve to organizers only.GetSponsorLiveInfoAsync(DisabledEventLiveValidationService.cs:50-51): reports every sponsor as belonging to a published event, with adefaultevent id and an empty name, so a consumer that renders the name simply shows nothing (remarks,DisabledEventLiveValidationService.cs:46-49).GetCurrentRoomSessionInfoAsync(DisabledEventLiveValidationService.cs:59-63): echoes the room's own id as the session id, with an empty title and a published flag oftrue. The remarks (DisabledEventLiveValidationService.cs:54-58) explain the choice: without a Conference module there is no schedule to consult, and returning aNotFoundinstead would turn the disabled-module stub into a hard rejection rather than a skipped check.
- Why it's built this way: failing open rather than closed is the right default here because the stub is only reached in a host that is not the authority on live windows. Blocking every poll, question, and scan in that configuration would be worse than skipping a check that a properly wired gRPC client will perform. The choice is explicit and documented per method, not accidental. The class is
internal, so nothing outside the*.Sharedassembly can take a direct dependency on the fail-open behavior: it is only ever reached through the interface. - Where it's used: registered by
ConferenceModule.RegisterDisabledStubsas a singletonIEventLiveValidationService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:24, the hook the module system calls when Conference is disabled in a host, declared atConferenceModule.cs:21next to the sibling bookmark stub at:23); thenReplaced by the gRPC adapter when a host callsAddConferenceEventLiveValidationClient(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:73-80). - Caveats / not-in-source: the stub's fail-open posture is safe only because every host that actually serves live traffic wires the gRPC client. Whether that holds for a given deployment is host configuration, not something this file can guarantee.
CurrentEventSelector
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:12· Level 8 · class (static)
- What it is: a static, generic helper that picks which event a landing surface should feature (live now, else the next upcoming, else the most recently ended) using the same live-window math the backend enforces. It also exposes the window computation and the DST-safe local-to-UTC conversion as public methods.
- Depends on: nothing first-party (BCL
TimeZoneInfo,DateOnly/DateTime, LINQ). It is generic over the caller's event model via accessor delegates. - Concept introduced, the shared selection algorithm parameterized by accessors.
[Rubric §34, Architecture Governance & Documentation]and[Rubric §1, SOLID](one algorithm serving many callers without a shared base type): several surfaces need "which event is current", the ADC home page, the Engagement live-event service, the Conference list pages, and two server-side handlers, and they do not all hold the same event model (the home page deserializes its own anonymous-endpoint shape). Rather than duplicate the classify-and-rank logic,SelectCurrentOrNext<TEvent>takesFunc<TEvent, ...>accessors for start date, end date, and time-zone id, so it works over any shape without coupling to a concrete type.[Rubric §27, Internationalization]also applies: the window math is computed per the event's IANA time zone, never per server local time. - Walkthrough
SelectCurrentOrNext<TEvent>(events, startDate, endDate, timeZoneId, utcNow)(CurrentEventSelector.cs:24-55, constrainedwhere TEvent : class): projects each event to its(StartUtc, EndUtc)window viaGetLiveWindowUtc(CurrentEventSelector.cs:32-38), then applies the preference order. Live events (StartUtc <= utcNow && utcNow < EndUtc) ordered by soonest to end (CurrentEventSelector.cs:40-44), else upcoming events (StartUtc > utcNow) ordered by soonest to start (CurrentEventSelector.cs:46-50), else the most recently ended event (OrderByDescending(EndUtc),CurrentEventSelector.cs:54). Returnsnullwheneventsis empty. Ties resolve by input order because LINQ'sOrderByis a stable sort (doc comment,CurrentEventSelector.cs:10).GetLiveWindowUtc(startDate, endDate, timeZoneId)(CurrentEventSelector.cs:66-76): computesstartLocalasStartDateat 00:00 (CurrentEventSelector.cs:71) andendLocalasEndDate + 1 dayat 00:00 (CurrentEventSelector.cs:72), resolves the zone withTimeZoneInfo.FindSystemTimeZoneById(CurrentEventSelector.cs:74) and converts both throughToUtc(CurrentEventSelector.cs:75). There is no catch around the zone lookup: the doc comment states the id always resolves becauseEventInvariants.EnsureTimeZoneIsValidguards every write path (CurrentEventSelector.cs:59-60, and the guard itself atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:82-98), so an unresolvable id is a data defect that must surface rather than be silently absorbed. The returned tuple names its second elementEndExclusiveUtc(CurrentEventSelector.cs:66), a reminder that the end bound is exclusive.ToUtc(localWallClock, timeZone)(CurrentEventSelector.cs:89-100): the DST guard. Both window boundaries land on local midnight, which is inside the spring-forward gap in zones that transition at 00:00 (the doc comment names America/Santiago and Asia/Beirut,CurrentEventSelector.cs:79-84); that wall time never existed, so a rawTimeZoneInfo.ConvertTimeToUtcwould throw. The method null-guards the zone (CurrentEventSelector.cs:91), re-kinds the input asUnspecified(CurrentEventSelector.cs:93), and shifts an invalid time forward by one hour into the hour that did exist (CurrentEventSelector.cs:94-97) before converting (CurrentEventSelector.cs:99). Ambiguous (fall-back) times resolve to the zone's standard offset, which isConvertTimeToUtc's own behavior.
- Why it's built this way: the accessor-delegate design lets one vetted implementation of the "current event" rule serve every surface, so the home page, the live layer, and the list-page default filter can never disagree about which event is featured. Colocating
GetLiveWindowUtchere keeps the window definition identical to the oneEventLiveInfoadvertises (its doc comment points at that record,CurrentEventSelector.cs:7), and makingToUtcpublic means the same spring-forward-gap fix is reused rather than re-derived: the home page's countdown calls it directly for that reason (ADCHome.razor.cs:253-259). - Where it's used: the Conference
ADCHomepage (ADCHome.razor.cs:179for the selection,ADCHome.razor.cs:258-259forToUtc); the sharedEventFilteredListPageBase, which resolves the default event filter once for every list page that derives from it (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Common/EventFilteredListPageBase.cs:180-188); the pages that call it directly,ActivityCreate(ActivityCreate.razor.cs:62),PublicActivityList(PublicActivityList.razor.cs:55),PublicEventList(PublicEventList.razor.cs:102),PublicSponsorList(PublicSponsorList.razor.cs:54),SponsorCreate(SponsorCreate.razor.cs:61),SpeakerDashboard(SpeakerDashboard.razor.cs:170), andSessionSelectionDashboard(SessionSelectionDashboard.razor.cs:68); the EngagementLiveEventService, which uses bothSelectCurrentOrNextandGetLiveWindowUtc(LiveEventService.cs:27,LiveEventService.cs:38); and server-side byGetNowNextHandler, which resolves the current event and its window for the Now/Next query (GetNowNextHandler.cs:101,GetNowNextHandler.cs:68) and byEventLiveValidationServiceitself, which delegates rather than repeating the math (EventLiveValidationService.cs:229). Callers holding anEventDTOusually go through theCurrentEventDefaultswrapper instead.
CurrentEventDefaults
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventDefaults.cs:8· Level 9 · class (static)
- What it is: a thin convenience wrapper over
CurrentEventSelectorspecialized to theEventDTOshape, so the pages and services that already work withEventDTOdo not repeat the same accessor lambdas. - Depends on:
CurrentEventSelector,EventDTO. - Concept, the type-specialized wrapper (DRY over the generic helper).
[Rubric §15, Best Practices & Code Quality]: the genericCurrentEventSelector.SelectCurrentOrNext<TEvent>needs three accessor delegates on every call. Since many callers passEventDTO, this wrapper binds those lambdas once, so a call site shrinks toSelectCurrentOrNext(events, utcNow). - Walkthrough: one method,
SelectCurrentOrNext(IEnumerable<EventDTO> events, DateTime utcNow)(CurrentEventDefaults.cs:17), which forwards toCurrentEventSelector.SelectCurrentOrNextwith the threeEventDTOaccessorse => e.StartDate,e => e.EndDate,e => e.TimeZone(CurrentEventDefaults.cs:18-23) and returns the selectedEventDTO?(null when the input is empty). No other logic: all the ranking lives in the generic helper. The doc comment notes that callers pass the role-appropriate candidate set (CurrentEventDefaults.cs:14), so filtering to published events stays the caller's job. - Why it's built this way: keeping the
EventDTOaccessors in one place means renaming anEventDTOdate or time-zone property is a single edit here, not a change scattered across every page that defaults an event filter. - Where it's used: the Conference
SessionListpage (SessionList.razor.cs:119), thePublicSessionListpage (PublicSessionList.razor.cs:170), each setting its default selected event id, andPublicSpeakerDetail(PublicSpeakerDetail.razor.cs:214). Callers passing a non-EventDTOmodel call the genericCurrentEventSelectordirectly.
NowNextSessionDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/NowNextDTO.cs:29· Level 0 · record
- What it is: one session row inside the "happening now / up next" snapshot. It carries just enough to render and deep-link a glanceable session tile: identity, title, room, and the start/end pair in both event-local wall clock and UTC.
- Depends on: nothing first-party (the
SessionIdentifierTypealias resolves through the solution-wideglobal usingset up inDirectory.Build.props); BCL only (DateTime,DateTimeOffset). This is an identity-less read projection, not a persisted-entity DTO, so unlike its neighbours in this folder it does not implement IBaseDTO<TIdentifierType>. - Concept introduced, the dual-clock read model.
[Rubric §9, API & Contract Design](assesses whether a contract hands each consumer the shape it needs without post-processing). This DTO ships each boundary time twice:StartsAtLocal/EndsAtLocalasDateTimewall clock in the event's time zone (NowNextDTO.cs:33-34) for a badge or widget that just prints the string, andStartsAtUtc/EndsAtUtcasDateTimeOffset(NowNextDTO.cs:35-36) for a caller doing its own time math. The doc comment on the parent states the split rationale (NowNextDTO.cs:6-7).[Rubric §12, Performance & Scalability]: precomputing both forms server-side keeps a mobile or widget client free of time-zone conversion, and the producing handler resolves the zone exactly once per request (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextHandler.cs:46). - Walkthrough: a positional
sealed recordwith seven parameters (NowNextDTO.cs:29-36):SessionId(the deep-link target),Title, a nullableRoomNamethat isnullwhen the session has no room assigned (NowNextDTO.cs:32), then the two localDateTimes and the two UTCDateTimeOffsets. There is noCreatefactory: this is a read-side projection assembled from an already-valid aggregate by GetNowNextHandler, not a domain value object that must guard its own invariants. - Why it's built this way: a positional record gives structural equality and immutability with no
boilerplate, which is all a read model needs. It is the row element of NowNextDTO
rather than a standalone contract, so it lives in that same file (ADR-042
Wave 8, cited in the doc comment at
NowNextDTO.cs:4). - Where it's used: nested as the
NowandNextlists on NowNextDTO; each row is built by the handler's privateToRowprojection over aSessionaggregate (GetNowNextHandler.cs:73), then served on the anonymous now-next endpoints of EventsController (EventsController.cs:171-180,187-194).
NowNextDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/NowNextDTO.cs:14· Level 1 · record
- What it is: the "happening now / up next" snapshot for a single event: which sessions are running at the query instant and which start next, plus whether the event is currently live.
- Depends on: NowNextSessionDTO (Level 0, the row type); the
EventIdentifierTypealias; BCL (IReadOnlyList<T>). - Concept introduced, the composed glanceable read model.
[Rubric §9, API & Contract Design](assesses purpose-built read contracts over exposing raw entities). Rather than make a widget page the full session list and filter client-side, this DTO is the entire payload of the now-next endpoint: one event's identity plus two pre-filtered session batches.[Rubric §5, Vertical Slice]: the snapshot is shaped by exactly one query's needs and is not reused across unrelated screens, which is why it lives beside the slice that produces it rather than in a shared "models" bucket. - Walkthrough: a positional
sealed recordwith five parameters (NowNextDTO.cs:14-19).EventIdandEventNamename the featured event (NowNextDTO.cs:15-16); the handler fills them from the selected Event aggregate (GetNowNextHandler.cs:71).IsLive(NowNextDTO.cs:17) istruewhen the event's live window contains the query instant. The window itself is computed by CurrentEventSelector and compared againstTimeProvider's UTC now (GetNowNextHandler.cs:68-69), so the flag is deterministic under test.Now(NowNextDTO.cs:18) holds the sessions whose UTC window brackets the query instant, ordered by start then room name (GetNowNextHandler.cs:52-56); it is empty outside session hours.Next(NowNextDTO.cs:19) holds the batch sharing the earliest future start, so parallel tracks surface together instead of one arbitrary winner (GetNowNextHandler.cs:59-66, and the doc comment atNowNextDTO.cs:13). Both batches areIReadOnlyList<NowNextSessionDTO>, so the shape is a fixed, ordered projection the caller cannot mutate.
- Why it's built this way: batching
Nextas a list rather than a single session is the modelling decision that makes the widget correct on a multi-track schedule. Local-plus-UTC times live on the row type, keeping this envelope thin. The endpoint is[AllowAnonymous]and output-cached under theNowNextCachepolicy (EventsController.cs:171-173) because the payload is public and changes with the clock, which is the[Rubric §12, Performance & Scalability]lever for a widget that polls. - Where it's used: returned as
Result<NowNextDTO>by GetNowNextHandler (GetNowNextHandler.cs:25) for GetNowNextQuery; exposed by EventsController both per event (GET {id}/now-next,EventsController.cs:174) and in the id-less "current event" form the home-screen widget calls (GET now-next,EventsController.cs:189). Both NowNextWidgetProvider and Engagement's INowNextService deliberately mirror this wire shape locally instead of referencing the type, so neither takes a project reference on Conference.
SessionCategoryItemDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionCategoryItemDTO.cs:8· Level 1 · record
- What it is: the DTO for one row of the many-to-many join between a session and a category item (the topic, format, level and similar tag taxonomies).
- Depends on: IBaseDTO<TIdentifierType>
(
SessionCategoryItemDTO.cs:1,8); theSessionCategoryItemIdentifierType,SessionIdentifierTypeandCategoryItemIdentifierTypealiases. - Concept introduced, the join-row DTO.
[Rubric §9, API & Contract Design](assesses contracts that mirror the relational model without leaking EF entities). This is the read-side twin of the SessionCategoryItem link entity: it carries its own surfaceIdplus the two foreign keys that define the association, and nothing else.[Rubric §8, Data Architecture]: a join with its own identity, rather than a bare composite key, is what lets the association be addressed, created and deleted as a resource in its own right. - Walkthrough: a
record classimplementing IBaseDTO<TIdentifierType> with threerequired initmembers (SessionCategoryItemDTO.cs:11-17):Id(the join row's own key),SessionId(FK to the parent session) andCategoryItemId(FK to the category item).requiredforces every member to be set at construction,initfreezes them afterwards. It shares its exact shape with SessionSpeakerDTO, where the family walkthrough lives. - Why it's built this way: a hand-declared record keeps the wire contract explicit and decoupled from the EF link entity (ADR-001 manual DTO mapping).
- Where it's used: nested as
SessionCategoryItemson SessionDTO (SessionDTO.cs:99); produced by SessionDTOMapper and filled on demand by navigation populators (ADR-002); it is also the request and response shape of AddSessionCategoryItemHandler and SessionCategoryItemsController.
SessionQuestionAnswerDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionQuestionAnswerDTO.cs:9· Level 1 · record
- What it is: the DTO linking a session to a question together with the speaker's answer value: the read-side row for one per-session questionnaire response.
- Depends on: IBaseDTO<TIdentifierType>
(
SessionQuestionAnswerDTO.cs:1,9); theSessionQuestionAnswerIdentifierType,SessionIdentifierTypeandQuestionIdentifierTypealiases. - Concept: the join-row DTO with a payload column, a variant of the shape introduced by
SessionCategoryItemDTO. Unlike the two pure two-FK joins, this one also
carries an attribute of the relationship.
[Rubric §9, API & Contract Design]: the answer belongs to the pairing of session and question, not to either side alone, so the join row is the only honest place to put it. - Walkthrough: a
record classimplementing IBaseDTO<TIdentifierType> with fourrequired initmembers (SessionQuestionAnswerDTO.cs:12-21):Id,SessionId(FK to the parent session),QuestionId(FK to the Question) and the distinguishingAnswerValuestring (SessionQuestionAnswerDTO.cs:21). That extra column is the only structural difference from the two pure join DTOs, and note it isrequired: an answer row with no answer cannot be constructed. - Why it's built this way: modelling the answer as a first-class join row (identity plus the answer value) lets the session own a replaceable collection of answers, and lets the wire contract stay independent of the EF link entity (ADR-001).
- Where it's used: nested as
SessionQuestionAnswerson SessionDTO (SessionDTO.cs:96); produced by SessionDTOMapper and navigation populators (ADR-002); carried by AddSessionQuestionAnswerHandler, its batch sibling BatchAddSessionQuestionAnswersHandler (the import path, which writes many answers per session in one command) and SessionQuestionAnswersController.
SessionSpeakerDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionSpeakerDTO.cs:8· Level 1 · record
What it is: the DTO for one row of the many-to-many join between a session and a speaker.
Depends on: IBaseDTO<TIdentifierType> (
SessionSpeakerDTO.cs:1,8); theSessionSpeakerIdentifierType,SessionIdentifierTypeandSpeakerIdentifierTypealiases.Concept: identical in shape to SessionCategoryItemDTO, the canonical two-FK join-row DTO. Both carry their own
Idplus the two association foreign keys and nothing else.Walkthrough: a
record classimplementing IBaseDTO<TIdentifierType> with threerequired initmembers (SessionSpeakerDTO.cs:11-17):Id(join row key),SessionId(FK to the parent session) andSpeakerId(FK to the Speaker). The three join DTOs in this Sessions folder form a near-identical family, an{Id, parent FK, target FK}triple per join table, with one of them adding a payload field:Type File:Line Notes (what differs) SessionSpeakerDTOMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionSpeakerDTO.cs:8Target FK is SpeakerId(:17).SessionCategoryItemDTOMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionCategoryItemDTO.cs:8Target FK is CategoryItemId(:17).SessionQuestionAnswerDTOMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionQuestionAnswerDTO.cs:9Target FK is QuestionId(:18), plus a requiredAnswerValuestring (:21).Why it's built this way: giving each join its own surface DTO, rather than exposing a raw composite key, keeps the child collections on SessionDTO addressable row by row and lets the contract stay independent of the EF link entities (ADR-001).
Where it's used: nested as
SessionSpeakerson SessionDTO (SessionDTO.cs:93); produced by SessionDTOMapper and navigation populators (ADR-002); written by AddSessionSpeakerHandler and exposed by SessionSpeakersController. Because the collection defaults to empty (see SessionDTO), a list query whose populator does not include this navigation returns sessions with no speakers rather than an error, so this is the collection to check first when a session list renders without speaker names.
SessionDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:15· Level 2 · record
- What it is: the full read-side contract for a conference session: the field-length constants, the scalar fields (title, schedule, status flags, media URLs), the foreign keys to event and room, and the three child collections (speakers, question answers, category items).
- Depends on: IBaseDTO<TIdentifierType>
and IConcurrencyAware, the two contracts it
implements (
SessionDTO.cs:1,15); its three child DTOs SessionSpeakerDTO, SessionQuestionAnswerDTO and SessionCategoryItemDTO; theSessionIdentifierType,EventIdentifierTypeandRoomIdentifierTypealiases. - Concept introduced, the DTO as the single source of the field caps.
[Rubric §15, Best Practices & Code Quality](assesses whether one fact lives in one place). The sevenconst intcaps at the top of this type (SessionDTO.cs:18-36) are the only declaration of the session field lengths in the system:TitleMaxLength500,DescriptionMaxLength4000,StatusMaxLength100,AccessibilityInfoMaxLength500,ResourceLinksMaxLength2000,LiveUrlMaxLength2000,RecordingUrlMaxLength2000. The doc comment explains the placement (SessionDTO.cs:8-13):Sharedis the lowest layer that Domain, Infrastructure and UI can all reach, so the caps sit here and every other layer consumes them. SessionInvariants re-exports them as its own constants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:16,19,22,31), which is what the domain guards, the EF configuration and the validators then read, while the UI form binds them straight off this type (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sessions/SessionFormModel.cs:38,42,46). One edit here moves the database column, the domain guard and the client-sideMaxLengthtogether. - Concept, the concurrency-aware read contract.
[Rubric §9, API & Contract Design]and[Rubric §8, Data Architecture]. BeyondIBaseDTO'sId, this DTO implements IConcurrencyAware, so it round-trips the EFRowVersiontoken (SessionDTO.cs:42). The API renders that token as the responseETag, and a client echoes it inIf-Matchon its next write; a write that states no precondition is refused with428 Precondition Requiredrather than falling back to last-write-wins (the contract's own remarks,MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:9-14). TheCA1819("properties should not return arrays") waiver lives once on the interface member in MMCA.Common (IConcurrencyAware.cs:18), not on each implementing DTO. - Walkthrough
- Field caps (
SessionDTO.cs:18-36): the sevenconst intvalues described above. - Identity and concurrency:
Id(SessionDTO.cs:39) andRowVersion, a non-nullablebyte[]defaulting to the empty collection literal[](SessionDTO.cs:42), satisfy the two implemented contracts. - Scalars (
SessionDTO.cs:45-84): arequired Title, an optionalDescription, an optionalStartsAt/EndsAtpair, a free-textStatus(Accepted, Declined, Waitlisted, Nominated), fourboolflags (IsInformed,IsConfirmed,IsServiceSession,IsPlenumSession,SessionDTO.cs:60-69), two URL strings kept asstring(LiveUrl,RecordingUrl,SessionDTO.cs:72,75), plusAccessibilityInfo,ResourceLinksand a nullableDurationin minutes. - Foreign keys: a
required EventId(SessionDTO.cs:87, every session belongs to an event) and an optionalRoomId(SessionDTO.cs:90, a session may not yet be placed in a room). - Child collections (
SessionDTO.cs:93-99):SessionSpeakers,SessionQuestionAnswersandSessionCategoryItems, each anIReadOnlyCollection<...>defaulted to[].
- Field caps (
- Why it's built this way: defaulting each child collection to
[]means a query that does not run the matching populator returns an empty collection instead of a null reference, so callers never null-check a navigation. The trade-off is that "not populated" and "genuinely empty" are indistinguishable on the wire, which is why a missing populator shows up as absent data rather than as an error. Manual DTO shaping (ADR-001) keeps the contract explicit; navigation populators (ADR-002) fill the child collections per query. - Where it's used: mapped from the Session aggregate by SessionDTOMapper, returned by the session read endpoints on SessionsController, and consumed by the Conference UI session pages through SessionFormModel.
- Caveats / not-in-source:
Statusis a free-textstring, not a closed enum, and this DTO does not itself constrain the allowed values; the eligibility rule lives in SessionInvariants (SessionInvariants.EnsureStatusIsEligible).
ISessionBookmarkValidationService
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/ISessionBookmarkValidationService.cs:11· Level 3 · interface
- What it is: the cross-module service contract the Engagement module calls to check whether a session may be bookmarked, and to list a given event's session ids, without referencing Conference domain entities directly.
- Depends on: Result and
ServiceContractAttribute, both from
MMCA.Common.Shared.Abstractions(ISessionBookmarkValidationService.cs:1,10); theSessionIdentifierTypeandEventIdentifierTypealiases. - Concept introduced, the cross-module boundary interface.
[Rubric §7, Microservices Readiness](assesses whether module coupling flows through an abstraction that can be re-satisfied over a wire once the modules split into separate services). The doc comment states the arrangement (ISessionBookmarkValidationService.cs:5-9): the interface is declared in the owning module'sSharedproject and implemented in Conference.Application, so Engagement depends only on the abstraction. When both modules run in one process, DI binds the real implementation; when Engagement runs as its own service, the same interface is satisfied by a gRPC adapter or by DisabledSessionBookmarkValidationService. - Concept, the
[ServiceContract]marker.[Rubric §34, Architecture Governance & Documentation]. The attribute on line 10 is not runtime behavior: it marks this interface as part of a published wire surface, and theServiceContractPurityTestsBasefitness rule scans every mapped assembly for types carrying it and fails the build if a contract type reaches back into the producing service's Domain, Application or Infrastructure layer (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:3-12). The marker is what turns "please keep this interface pure" into an enforced rule (ADR-007). - Walkthrough: two async members, both taking
CancellationTokenas the final parameter per the codebase convention.ValidateSessionForBookmarkAsync(SessionIdentifierType, CancellationToken)(ISessionBookmarkValidationService.cs:20) returnsTask<Result>and, per its doc comment (ISessionBookmarkValidationService.cs:13-16), checks that the session exists, is not a service session (BR-91) and has an eligible status (BR-49). The in-process implementation delegates both checks to SessionInvariants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:33,38), so the module boundary and the aggregate enforce the same rule text.GetSessionIdsByEventAsync(EventIdentifierType, CancellationToken)(ISessionBookmarkValidationService.cs:31) returnsTask<Result<IReadOnlyCollection<SessionIdentifierType>>>: the ids of every session in an event, used by Engagement for event-scoped bookmark filtering (BR-58). Note that the collection is wrapped in aResult, which the doc comment justifies (ISessionBookmarkValidationService.cs:28-30): once this call can cross a gRPC boundary, "Conference is unreachable" is a distinct outcome from "this event has no sessions", and an empty list must not silently stand in for an outage.
- Why it's built this way: returning Result instead of
throwing lets Engagement fold a validation failure into its own command result; keeping the interface
in
Shared(notApplication) is what allows a gRPC adapter or the disabled stub to be substituted without Engagement ever seeing Conference internals (ADR-007 gRPC extraction, ADR-008 service topology). - Where it's used: injected into Engagement's
CreateBookmarkHandler and
GetUserBookmarksHandler. Three
implementations satisfy it depending on topology: the in-process
SessionBookmarkValidationService,
the wire-crossing
SessionBookmarkValidationServiceGrpcAdapter,
and DisabledSessionBookmarkValidationService. The
Conference service exposes the server side of it over gRPC in
SessionBookmarksGrpcService.
DisabledSessionBookmarkValidationService
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DisabledSessionBookmarkValidationService.cs:30· Level 4 · class (internal sealed)
- What it is: the no-op stub of ISessionBookmarkValidationService, registered when the Conference module is present in a host but disabled. It approves every validation and reports no sessions per event.
- Depends on: ISessionBookmarkValidationService (the
interface it implements) and Result from
MMCA.Common.Shared.Abstractions(DisabledSessionBookmarkValidationService.cs:1). - Concept introduced, the disabled-module stub.
[Rubric §7, Microservices Readiness](assesses graceful degradation when an owning module is out of process) and[Rubric §34, Architecture Governance & Documentation](degradation is a named, greppable type rather than a missing DI binding). The doc comment spells out the trade-off (DisabledSessionBookmarkValidationService.cs:5-28):ValidateSessionForBookmarkAsyncreturns success so Engagement's bookmark handlers still complete, at the cost of skipping the BR-49/BR-91 eligibility checks, with the real validation happening at the Conference service when bookmark events flow through the broker. It also records the codebase-wide convention that each owning module's*.Sharedproject ships aDisabled*Servicestub for the cross-module interfaces it exposes: its own file-neighbour DisabledEventLiveValidationService, plusDisabledBookmarkCountServicein Engagement andDisabledAttendeeQueryServicein Identity. - Concept,
internalplusInternalsVisibleToas the registration boundary. The class isinternal sealed(DisabledSessionBookmarkValidationService.cs:30), and the only non-test assembly allowed to see it is the module's own API project (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.Shared.csproj:4). No host can new it up or register it by hand: the stub reaches the container exactly one way, through ConferenceModule.RegisterDisabledStubs(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:21-25), which IModule declares as an opt-in default no-op (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/IModule.cs:34) andModuleLoaderinvokes for a disabled module (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:108). - Walkthrough: two one-line members, both returning already-completed tasks.
ValidateSessionForBookmarkAsync(...)(DisabledSessionBookmarkValidationService.cs:33-34) returnsTask.FromResult(Result.Success()), approving unconditionally.GetSessionIdsByEventAsync(...)(DisabledSessionBookmarkValidationService.cs:37-38) returnsTask.FromResult(Result.Success<IReadOnlyCollection<SessionIdentifierType>>([])), a successful empty collection, so an event-filtered bookmark query degrades to "no bookmarks for this event" rather than throwing or reporting an outage.
- Why it's built this way:
Task.FromResultavoids allocating an async state machine on a path that can run per request, and the collection literal[]gives a benign empty answer. Making degradation explicit and named, rather than leaving the interface unbound and letting DI throw at resolution time, is the governance point (ADR-008 service topology). - Where it's used: registered as a singleton by
ConferenceModule.RegisterDisabledStubs(ConferenceModule.cs:23) alongside theIEventLiveValidationServicestub. In the deployed split-service topology it is a fallback that gets overwritten: the extracted Engagement service callsAddConferenceSessionValidationClient(MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:281), and that registration usesServiceCollectionDescriptorExtensions.Replaceso the resolved ISessionBookmarkValidationService is the gRPC adapter, whether the prior binding was the real in-process service or this stub (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:26-33). The stub is therefore the safety net for a host that loads Conference-disabled and wires no gRPC client, not the path the ADC deployment takes. - Caveats / not-in-source: the class doc comment describes the gRPC route as "a future
MMCA.ADC.Conference.Contractspackage" (DisabledSessionBookmarkValidationService.cs:14-15), and the Engagement service's header comment says this host registers the stub manually (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:14-18). Both comments trail the code: theMMCA.ADC.Conference.Contractsproject and its adapter exist, and the Engagement service registers the gRPC client. Trust the registrations cited above over those two comments.
CategoryItemDistribution
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/CategoryDistributionDTO.cs:27· Level 0 · record (sealed)
- What it is: the submission breakdown for a single category item (for example "Cloud", or "300 - Intermediate"): how many sessions tagged with that item were submitted, and how many landed in each status bucket. It is the innermost leaf of the category-balance analysis an organizer reads while selecting sessions.
- Depends on:
CategoryItemIdentifierType(the module id alias, a solution-wideglobal using, so no first-party link, see the primer on strongly-typed identifier aliases). No first-party type references beyond the alias. - Concept introduced, the decision-support read model.
[Rubric §6, CQRS & Event-Driven](assesses read models shaped for the query rather than the write schema) and[Rubric §12, Performance & Scalability](assesses computing aggregates server-side instead of shipping raw rows). This unit is a family of pure analytics DTOs that back the organizer's session-selection dashboard. Unlike the entity-mirroring DTOs elsewhere in this chapter (for exampleSessionDTO), none of them implementIBaseDTO: they carry noId, are never persisted, and are not addressable resources. They are the output of read-side query handlers that fold hundreds of session rows into counts and scores the UI can render directly.CategoryItemDistributionis the leaf of that fold: one row per category item, pre-counted. - Walkthrough: six
required initmembers (CategoryDistributionDTO.cs:30-45).CategoryItemIdandCategoryItemNameidentify the item; then four counts,TotalSubmitted(excludes declined, line 36),AcceptedCount(status "Accepted" or null, line 39),AcceptQueueCount(status "Accept_Queue", line 42), andPendingCount(Nominated or Waitlisted, line 45). Every field isrequired, so a distribution row is never half-populated. The status vocabulary is the same loose Sessionize-sourced set thatSessionDTO.Statuscarries; the counts are bucketed by the query handler, not by the record. - Why it's built this way: pushing the count-by-status math into the handler and shipping just the totals keeps the dashboard client dumb and cheap, an organizer viewing category balance across a whole event never fetches individual sessions.
- Where it's used: nested as the
Itemscollection onCategoryGroupDistribution; produced byGetCategoryDistributionHandlerin Conference.Application, ultimately surfaced throughSessionSelectionController.
ScoreEventSessionsResultDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/SessionAiScoreDTO.cs:68· Level 0 · record (sealed)
- What it is: the tiny outcome payload of a batch AI scoring operation: how many of an event's sessions were scored and how many failed. It is the response body a caller gets back after asking the system to score an event's sessions.
- Depends on: nothing first-party. Two
intcounts only. - Concept reinforced, the command result DTO.
[Rubric §9, API & Contract Design](a write operation returns a small, honest summary of what it did). Unlike the query read models around it, this is the result of an action (scoring), not a projection of data. It reports partial success explicitly: scoring hundreds of sessions against an external AI model is expected to have some failures, so the contract carries both a success count and a failure count rather than an all-or-nothing boolean. - Walkthrough: two
required initmembers (SessionAiScoreDTO.cs:71-74),SessionsScoredandSessionsFailed. That is the whole record; there is no aggregate id because a batch score spans an entire event. - Why it's built this way: separating scored from failed lets the organizer UI show "48 scored, 2 failed" and offer a retry, rather than hiding partial progress behind a single flag.
- Where it's used: returned by
ScoreEventSessionsHandlerin Conference.Application; the individual scores it writes are read back asSessionAiScoreDTOrows.
[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).
SessionAiScoreDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/SessionAiScoreDTO.cs:6· Level 0 · record (sealed)
- What it is: the richest leaf in this family: the AI-generated score for one session, bundled with enough display context (title, status, speaker localities, categories, level) that a dashboard row is self-contained. It is what the organizer sees when the AI has ranked an event's submissions.
- Depends on:
SessionIdentifierTypealias (no first-party link). All other members are primitives,string,DateTime, orIReadOnlyList<string>. - Concept introduced, the self-contained scored row.
[Rubric §12, Performance & Scalability](assesses shaping the payload so the client does no secondary lookups) and[Rubric §9, API & Contract Design]. The interesting move is that the score does not travel alone: alongside the numbers it carriesSpeakerLocalities,SessionCategories, andSessionLevel(lines 56-62), so the organizer dashboard can print a complete, sortable row for each session without an N+1 fetch back to theSession,Speaker, orCategoryaggregates.[Rubric §13, Observability & Operability]: the record also records how the number was produced,ModelUsed(line 39),PromptVersion(line 47), andScoredOn(line 50), so a score is auditable, comparable against another score, and its staleness visible. - Walkthrough:
SessionId+SessionTitle(lines 9-12, required) identify the row. Then the scores, allrequired decimalon a 1.0 to 10.0 scale: anOverallScore(line 15) plus six dimension sub-scores,TopicRelevanceScore,DescriptionQualityScore,NoveltyScore,ActionableTakeawaysScore,DepthOrInsightQualityScore,CredibilityExperienceScore(lines 18-33).Reasoning(line 36, required) holds the model's free-text justification.ModelUsed(line 39),PromptVersion(line 47), andScoredOn(line 50) capture provenance, all threerequired.PromptVersionis the reviewer-brief contract version behind the numbers, shapedyyyy-MM-dd.Nor the sentinellegacyfor rows written before the column existed (documented atSessionAiScoreDTO.cs:41-46); the value comes from the scoring service's own constant, today"2026-09-04.1"(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AnthropicScoringService.cs:37). The tail members are optional display context:Status(line 53, nullable),SpeakerLocalitiesandSessionCategories(lines 56-59, defaulted to[]so never null), andSessionLevel(line 62, nullable).SpeakerLocalitiesis the speaker-locality convention surfacing here: those tier names come from aCategoryItemunder the "Where are you traveling from" category (Sessionize id 121854), not from any geographic field onSpeaker(resolved bySpeakerLocalityHelperin Conference.Application,SpeakerLocalityHelper.cs:19-33). - Why it's built this way: embedding display context in the score DTO avoids per-row lookups on a dashboard that shows every session in an event at once, and recording the model id, the prompt contract version, and the timestamp keeps an AI-produced number honest and re-scorable. Two scores are only comparable when both the model and the prompt behind them match, so surfacing
PromptVersionnext toModelUsedis what lets a reader tell a genuine ranking change from a change of reviewer brief. - Where it's used: nested as the
AiScorescollection onSessionSelectionDashboardDTO; projected from theSessionAiScoreentity byGetSessionSelectionDashboardHandler(GetSessionSelectionDashboardHandler.cs:386carriesPromptVersionacross), written byScoreEventSessionsHandler(which stampsaiScoringService.ModelIdandaiScoringService.PromptVersion,ScoreEventSessionsHandler.cs:83, and returns aScoreEventSessionsResultDTOsummary), and read back throughSessionSelectionController. - Caveats / not-in-source: the 1.0 to 10.0 range and the status vocabulary are documented in the property comments and enforced by the scoring handler and the external model prompt, not by this record. The
legacysentinel is likewise a convention, not a constant on this type: rows predating the column were backfilled with it by theAddSessionAiScorePromptVersionmigration (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionAiScoreConfiguration.cs:59-61).
[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).
SimilarSessionPair
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/ContentSimilarityDTO.cs:14· Level 0 · record (sealed)
- What it is: two sessions judged to have overlapping content, with a computed similarity score and the specifics they share. It is one row of the "these look redundant" list organizers use to avoid accepting duplicate talks.
- Depends on:
SessionIdentifierTypealias (no first-party link). Otherwisestring,double, andIReadOnlyList<string>. - Concept reinforced, the analysis-result row.
[Rubric §12, Performance & Scalability](the similarity math runs server-side, the wire carries only the verdict) and[Rubric §9, API & Contract Design]. LikeSessionAiScoreDTO, it is self-contained: each end carries id, title, and status so the UI can render and deep-link both sessions without a follow-up fetch, and it names why they matched (SharedCategoryItems,SharedKeywords) so the verdict is explainable rather than an opaque number. - Walkthrough: the "A" end,
SessionAId/SessionATitle/SessionAStatus(lines 17-23), and the "B" end,SessionBId/SessionBTitle/SessionBStatus(lines 26-32); the two ids and titles arerequired, the two statuses nullable.SimilarityScore(line 35) is arequired doublebetween 0.0 and 1.0.SharedCategoryItemsandSharedKeywords(lines 38-41, bothrequired IReadOnlyList<string>) explain the overlap. - Why it's built this way: shipping the shared categories and keywords alongside the score turns "0.83 similar" into an actionable, auditable finding an organizer can trust when declining a redundant submission.
- Where it's used: nested as the
Pairscollection onContentSimilarityDTO; produced byGetContentSimilarityHandlerin Conference.Application.
SpeakerLocalitySummary
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/SessionSelectionDashboardDTO.cs:45· Level 0 · record (sealed)
- What it is: the roll-up for one locality tier (for example "Atlanta and Suburbs", "Georgia"): how many speakers fall in that tier and how their sessions break down by status. It backs the "are we programming enough local speakers?" view.
- Depends on: nothing first-party. A
stringtier name and fourintcounts. - Concept reinforced, locality as a category, not a field.
[Rubric §4, DDD](assesses modeling a real domain concept faithfully rather than bolting on an ad-hoc attribute). TheLocalityTierstring is not read from any geographic property onSpeaker: ADC tracks where a speaker travels from through aCategoryItemunder the "Where are you traveling from" category (Sessionize id 121854).SpeakerLocalityHelper(Conference.Application) resolves a speaker's tier from their category-item assignments (SpeakerLocalityHelper.cs:19-33) and even flags Atlanta/Georgia/Surrounding as "local" (SpeakerLocalityHelper.cs:41-49). This DTO is the pre-tallied output of that resolution. - Walkthrough: five
required initmembers (SessionSelectionDashboardDTO.cs:48-60).LocalityTiernames the tier;SpeakerCountcounts speakers in it;SessionCounttotals their sessions;AcceptedSessionCountandAcceptQueueSessionCount(status "Accept_Queue") break those down. The status buckets mirror the ones onCategoryItemDistribution, keeping the vocabulary consistent across the dashboard. - Why it's built this way: pre-counting by tier lets the organizer see local-versus-remote balance at a glance; deriving locality from the category system (rather than a speaker field) keeps the model aligned with how the data actually arrives from Sessionize.
- Where it's used: nested as the
SpeakerLocalitycollection onSessionSelectionDashboardDTO; assembled byGetSessionSelectionDashboardHandlerin Conference.Application.
SpeakerSessionSummary
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/SpeakerSessionOverlapDTO.cs:37· Level 0 · record (sealed)
- What it is: a compact summary of one submitted session as it appears inside a speaker's overlap entry: id, title, status, and the category tags on it. It is a leaf of the speaker-overlap view, not a general session projection.
- Depends on:
SessionIdentifierTypealias (no first-party link). Otherwisestring, nullablestring, andIReadOnlyList<string>. - Concept reinforced, the purpose-shaped leaf.
[Rubric §6, CQRS & Event-Driven]. This carries far less than the fullSessionDTO: it exists only to list, under a speaker, the sessions that speaker submitted, so it drops everything the overlap review does not need (schedule, media links, concurrency token).CategoryItemNamesis a flat list of names rather than join rows because the review just needs to read the tags, not edit them. - Walkthrough: four
required initmembers (SpeakerSessionOverlapDTO.cs:39-49).SessionIdandTitleidentify the session;Status(nullable) is the loose Sessionize status;CategoryItemNameslists its category tags by name. - Why it's built this way: shaping a minimal per-session leaf keeps the speaker-overlap payload small even when a speaker has several submissions, and pre-resolving category names (rather than ids) means the UI needs no
CategoryItemlookup. - Where it's used: nested as the
Sessionscollection onMultiSessionSpeaker; assembled byGetSpeakerSessionOverlapHandlerin Conference.Application.
CategoryGroupDistribution
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/CategoryDistributionDTO.cs:14· Level 1 · record (sealed)
- What it is: the distribution for a single category (for example "Track" or "Level") together with the per-item breakdown inside it. It is the middle tier of the category-balance analysis, one level up from
CategoryItemDistribution. - Depends on:
CategoryItemDistribution(itsItemscollection);ConferenceCategoryIdentifierTypealias (no first-party link). - Concept reinforced, the composition tier.
[Rubric §6, CQRS & Event-Driven]. The read model mirrors the category, category-item hierarchy of theCategoryaggregate as a nested DTO graph shaped for display: a category names itself, then owns the list of its items' distributions. - Walkthrough: three
required initmembers (CategoryDistributionDTO.cs:17-23).CategoryIdandCategoryTitleidentify the category;Itemsis arequired IReadOnlyList<CategoryItemDistribution>, one leaf per item. - Why it's built this way: grouping item distributions under their parent category lets the dashboard render one balance table per category (a Track table, a Level table) without the client having to regroup a flat list.
- Where it's used: nested as the
Categoriescollection onCategoryDistributionDTO; produced byGetCategoryDistributionHandlerin Conference.Application.
ContentSimilarityDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/ContentSimilarityDTO.cs:7· Level 1 · record (sealed)
- What it is: the top of the content-similarity analysis: a single wrapper around the list of similar-session pairs, sorted most-similar first. It is the payload behind the "possible duplicate talks" panel.
- Depends on:
SimilarSessionPair(itsPairscollection). No other first-party types. - Concept reinforced, the analysis envelope.
[Rubric §9, API & Contract Design]. Wrapping the pair list in a named record (rather than returning a bare array) gives the endpoint a stable, extensible shape: future summary fields (a threshold, a count) can be added without breaking the contract. - Walkthrough: one
required initmember (ContentSimilarityDTO.cs:10),Pairs, anIReadOnlyList<SimilarSessionPair>documented as sorted by similarity score descending. The sort is the handler's responsibility, not the record's. - Why it's built this way: a single-field envelope keeps the read contract symmetric with the other decision-support DTOs (each analysis has its own top-level type) and leaves room to grow.
- Where it's used: produced by
GetContentSimilarityHandlerin Conference.Application; the pairs it wraps drive the redundancy panel of the selection dashboard.
MultiSessionSpeaker
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/SpeakerSessionOverlapDTO.cs:18· Level 1 · record (sealed)
- What it is: a speaker together with the sessions they submitted (one or more), plus a flag for whether they already have an accepted talk. Despite the name it includes single-session speakers too; it is named for the review scenario it powers (organizers should accept at most one session per speaker).
- Depends on:
SpeakerSessionSummary(itsSessionscollection);SpeakerIdentifierTypealias (no first-party link). - Concept reinforced, the review-shaped grouping.
[Rubric §6, CQRS & Event-Driven]and[Rubric §12, Performance & Scalability]. The read model is grouped by speaker (not by session) precisely because the decision it supports is per-speaker, and it precomputesHasAcceptedSessionso the UI can immediately flag a speaker who already has a talk in, without scanning their session list client-side. - Walkthrough: five members (
SpeakerSessionOverlapDTO.cs:21-33).SpeakerIdandSpeakerName(required) identify the speaker;LocalityCategory(line 27, nullable) is their locality tier from the "Where are you traveling from" category (id 121854), the same category-driven locality conventionSpeakerLocalitySummaryrolls up;HasAcceptedSession(required bool) is the precomputed accept flag;Sessions(required) is theIReadOnlyList<SpeakerSessionSummary>of their submissions. - Why it's built this way: grouping submissions under the speaker and precomputing the accept flag makes the "one talk per speaker" rule enforceable at a glance, which is the entire purpose of the overlap view.
- Where it's used: nested as the
Speakerscollection onSpeakerSessionOverlapDTO; assembled byGetSpeakerSessionOverlapHandlerin Conference.Application.
CategoryDistributionDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/CategoryDistributionDTO.cs:7· Level 2 · record (sealed)
- What it is: the top of the category-balance analysis: the full distribution of an event's sessions across every category, grouped by category then by item. Organizers use it to judge whether the accepted program is balanced across tracks and levels.
- Depends on:
CategoryGroupDistribution(itsCategoriescollection), which in turn ownsCategoryItemDistributionleaves. - Concept reinforced, the three-tier read model.
[Rubric §6, CQRS & Event-Driven]. This completes the category, category-group, category-item nesting:CategoryDistributionDTO→ manyCategoryGroupDistribution→ manyCategoryItemDistribution. The whole tree is computed once, server-side, from the event's sessions and theirCategoryItemassignments. - Walkthrough: one
required initmember (CategoryDistributionDTO.cs:10),Categories, anIReadOnlyList<CategoryGroupDistribution>. The record is a pure envelope; all the counts live in the leaves. - Why it's built this way: a single composite tree means the dashboard's category-balance view is one fetch, and each level maps cleanly onto a UI grouping (category heading, item rows, status columns).
- Where it's used: produced by
GetCategoryDistributionHandler; also nested as theCategoryDistributionmember ofSessionSelectionDashboardDTO, and surfaced throughSessionSelectionController.
SpeakerSessionOverlapDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/SpeakerSessionOverlapDTO.cs:8· Level 2 · record (sealed)
- What it is: the top of the speaker-overlap analysis: every speaker who submitted at least one session for an event, ordered so multi-session speakers surface first. It backs the "watch for speakers with multiple submissions" review.
- Depends on:
MultiSessionSpeaker(itsSpeakerscollection), which in turn ownsSpeakerSessionSummaryleaves. - Concept reinforced, the ordered analysis envelope.
[Rubric §9, API & Contract Design]. LikeContentSimilarityDTO, it is a single-field wrapper around a list, and the ordering (multi-session speakers first) is a documented contract the handler upholds so the UI can show the speakers who need attention at the top. - Walkthrough: one
required initmember (SpeakerSessionOverlapDTO.cs:11),Speakers, anIReadOnlyList<MultiSessionSpeaker>sorted multi-session-first. - Why it's built this way: a named envelope keeps the contract consistent with the sibling analyses and leaves room to add summary fields, while the sort order encodes the review priority directly into the payload.
- Where it's used: produced by
GetSpeakerSessionOverlapHandler; also nested as theSpeakerOverlapmember ofSessionSelectionDashboardDTO.
SessionSelectionDashboardDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.DecisionSupport·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DecisionSupport/SessionSelectionDashboardDTO.cs:8· Level 3 · record (sealed)
- What it is: the composite that ties the whole family together: one event's session-selection dashboard, aggregating the headline counts, the category distribution, the speaker overlap, the locality breakdown, and the AI scores into a single payload.
- Depends on:
CategoryDistributionDTO,SpeakerSessionOverlapDTO,SpeakerLocalitySummary, andSessionAiScoreDTO(its members and collections);EventIdentifierTypealias (no first-party link). - Concept reinforced, the composite dashboard read model.
[Rubric §6, CQRS & Event-Driven](a query-shaped DTO assembled from several independent analyses) and[Rubric §12, Performance & Scalability](one round trip instead of four). This is the root of the decision-support graph: rather than make the organizer UI call one endpoint per analysis, a single query composes all of them, plus the top-line event counts, into one immutable snapshot. - Walkthrough:
EventId+EventName(lines 11-14, required) identify the event. Fiverequired intheadline counts follow,TotalSessions(non-service sessions),AcceptedSessions,AcceptQueueSessions,PendingSessions,DeclinedSessions(lines 17-29), the same status buckets the leaf DTOs use, tallied at event scope. Then the four analysis members, allrequired:CategoryDistribution(line 32),SpeakerOverlap(line 35),SpeakerLocality(line 38, anIReadOnlyList<SpeakerLocalitySummary>), andAiScores(line 41, anIReadOnlyList<SessionAiScoreDTO>, documented as empty until an AI scoring run has happened). - Why it's built this way: bundling the counts and all four analyses into one composite lets the organizer dashboard render its entire face from a single fetch, and lets the read side cache one blob per event. The
AiScoreslist being allowed to arrive empty keeps the dashboard usable before anyScoreEventSessionsResultDTOrun has produced scores. - Where it's used: returned by
GetSessionSelectionDashboardQuery(assembled byGetSessionSelectionDashboardHandlerin Conference.Application); served bySessionSelectionControllerand consumed by the organizer session-selection dashboard page in the Conference UI.
LinkUserRequest
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/LinkUserRequest.cs:6· Level 0 · record (sealed)
- What it is: the request body a client sends to manually bind an Identity user to a
Speaker(BR-209,LinkUserRequest.cs:4). It carries exactly one field, theUserIdto link. - Depends on: nothing first-party. Its one member is typed over the
UserIdentifierTypealias, which isintand is authored in the Identity module (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/MMCA.ADC.Identity.GlobalUsings.IdentifierType.cs:2) yet compiled into every other project by a linked<Compile Include>inMMCA.ADC/Directory.Build.props:94-95. That is how a Conference contract can name an Identity id without referencing the Identity assembly (see the primer on identifier-type aliases). - Concept introduced, the request record (API input contract).
[Rubric §9, API & Contract Design](assesses whether inbound payloads are declared as explicit, typed contracts rather than loose parameters) and[Rubric §7, Microservices Readiness](the contract lives inShared, the project a caller can reference without pulling in Conference's Domain). Where the DTOs below are outbound read shapes, this is an inbound write shape: asealed recordwith a singlerequired initUserId(LinkUserRequest.cs:9).requiredmeans the model binder cannot leave it unset, andinitmakes it immutable once bound, so a controller receives a read-only value rather than a mutable bag. The type is deliberately tiny: it exists so the link endpoint has a named, versionable body instead of a bare route or query scalar. - Walkthrough: one member,
UserId(LinkUserRequest.cs:9), the Identity-side id to attach to the speaker. There is noSpeakerIdon the body: that comes from the route (the speaker being edited). - Why it's built this way: strongly typing the body over the
UserIdentifierTypealias keeps "who" named end to end, and placing it inSharedlets the UI and any future extracted client bind the same contract without a Domain reference. - Where it's used: bound by
SpeakersController.LinkUserAsync, aPUT /Speakers/{id}/linkgated by[HasPermission(ConferencePermissions.SpeakersManage)](MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:374-391), which forwardsrequest.UserIdinto theLinkUserToSpeakerCommand(SpeakersController.cs:383), then evicts theconference:speakersoutput-cache tags and answers204 No Content(SpeakersController.cs:389-390). The command's handler enforces BR-208 first (no other speaker may already hold thatLinkedUserId, otherwise aSpeaker.UserAlreadyLinkedinvariant failure comes back) and only then links and raisesSpeakerLinkedToUseron the aggregate before the save (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:42-55,57-64), so the outbox row lands in the same transaction as the link itself, which is exactly what the handler's own comment records (ADR-003,LinkUserToSpeakerHandler.cs:60-62).
RatingQuestionSummary
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:22· Level 0 · record (sealed)
- What it is: one row of a session's aggregated rating feedback, the per-question roll-up of a
numeric rating: the question, its average score, and how many responses fed that average. It is a
child component of
SessionFeedbackDTO. - Depends on: nothing first-party (uses the
QuestionIdentifierTypealias,int); BCL only. - Concept introduced, the hand-built query-projection record.
[Rubric §6, CQRS & Event-Driven](assesses read models shaped for the query, not the table) and[Rubric §9, API & Contract Design]. Unlike the entity DTOs later in this part, this record does not implementIBaseDTO<TIdentifierType>and is not produced by a Mapperly mapper: it is a bespoke aggregation shape assembled by a query handler from a group-by over answers. It exists purely as the wire shape of a computed report. - Walkthrough: four
required initmembers (SessionFeedbackDTO.cs:25-34),QuestionId,QuestionText(so the client renders a label without a second lookup),AverageRating(adouble, the computed mean), andResponseCount(the sample size behind that mean). The mean is computed in memory over the answers that parse as integers underCultureInfo.InvariantCulture(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionFeedback/GetSessionFeedbackHandler.cs:75-88), soResponseCountcounts parseable ratings, not raw answer rows. - Why it's built this way: carrying
QuestionTextandResponseCountalongside the average makes the record self-describing, so a UI can show "4.6 (from 32 responses)" straight from the payload. - Where it's used: nested in
SessionFeedbackDTO.Ratings; built byGetSessionFeedbackHandler. - Caveats / not-in-source: a rating question whose answers are all unparseable produces no
summary row at all rather than a zero-count one, because the handler only adds the record when at
least one value parsed (
GetSessionFeedbackHandler.cs:81-90). A consumer therefore cannot tell "nobody rated it" from "the question was never asked" out of this payload alone.
SponsorTier
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sponsors·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:12· Level 0 · enum
What it is: the sponsorship package a conference sponsor bought, from
Platinumdown toCommunity. It is the one enum in the Sponsors contract and it drives where a sponsor appears in the public sponsor strip.Depends on: nothing. Four named
intmembers, no attributes, no BCL types beyond the enum itself.Concept introduced, the ordinal-as-ordering enum.
[Rubric §9, API & Contract Design](assesses whether a contract's vocabulary is closed and explicit rather than a loose string) and[Rubric §15, Best Practices & Code Quality]. Two conventions are visible in the declaration and both are load-bearing:- The numeric values are the display order, stated in the type's own doc comment
(
SponsorTier.cs:4-6):Platinum = 0,Gold = 1,Silver = 2,Community = 3(SponsorTier.cs:15-24). A plain ascending sort therefore renders the largest package first with no lookup table, andPublicSponsorListrelies on exactly that: it groups byTierand calls.OrderBy(g => g.Key)(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sponsors/PublicSponsorList.razor.cs:88-92), breaking ties inside a tier bySortthenName. Renumbering a member would silently reorder the public page. - It is zero-based because CA1008 requires a zero member (
SponsorTier.cs:9-11), which has the side effect that an omitted tier defaults to the top package rather than to an "unknown" bucket. The type's remarks call this out rather than leaving it as an accident.
Contrast this with the loose status strings elsewhere in the Conference contract (for example
SessionDTO.Status, a nullablestringcarrying Sessionize's vocabulary as free text and bounded only by a length cap,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:24,57): tiers are sold by ADC itself, so the set is closed and can be an enum.- The numeric values are the display order, stated in the type's own doc comment
(
Walkthrough: four members with explicit values (
SponsorTier.cs:15-24). There is noNone,Unknown, or[Flags]member: a sponsor always has exactly one package.Why it's built this way: encoding package rank in the ordinal keeps ordering logic out of the UI and out of SQL; the explicit values (rather than implicit ones) make the ordering contract visible in the source so a future insertion has to be a deliberate decision.
Where it's used: the
Sponsoraggregate stores it (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:24) and takes it on create and update (Sponsor.cs:65,108,155); it crosses the API boundary onSponsorDTO.Tierand onSponsorCreateRequest/SponsorUpdateRequest; the UI keys its grouped view model on it (PublicSponsorList.razor.cs:41) and renders the label through a localized resource key,L[$"Tier.{tier}"](PublicSponsorList.razor.cs:44), which is[Rubric §27, i18n]in miniature: the enum member name is the resource key, so the label translates without a switch statement.
TextQuestionResponses
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:38· Level 0 · record (sealed)
- What it is: the free-text counterpart to
RatingQuestionSummary: all the individual text answers given to one non-rating feedback question, grouped under that question. Also a child component ofSessionFeedbackDTO. - Depends on: nothing first-party (uses the
QuestionIdentifierTypealias); BCL only. - Concept: the hand-built query-projection record (see
RatingQuestionSummary). Where a rating collapses to a mean, text answers cannot be averaged, so they are grouped verbatim. - Walkthrough: three
required initmembers (SessionFeedbackDTO.cs:41-47),QuestionId,QuestionText, andResponses(anIReadOnlyList<string>, the raw text answers). Exposing the list asIReadOnlyList<string>signals the payload is a read-only snapshot. Which questions land here is decided by elimination: the handler routes a question toRatingsonly when itsQuestionTypeis the literal"Rating", and everything else falls into this record (GetSessionFeedbackHandler.cs:73,92-100). - Why it's built this way: grouping by question (rather than returning a flat answer list) lets the speaker dashboard render one comment block per prompt without regrouping client-side.
- Where it's used: nested in
SessionFeedbackDTO.TextResponses; built byGetSessionFeedbackHandler.
ActivityDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Activities·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:15· Level 1 · record (class)
- What it is: the read-model shape of an
Activity, a conference social or networking slot (a party, a coffee connect, an after-party, the closing ceremony). It carries the name and blurb, the event-local start and end times, an optional off-site venue, a display tie-breaker, and the FK to its owning event, plus the field-length constants every other layer reads. - Depends on:
IBaseDTO<TIdentifierType>,IConcurrencyAware(both fromMMCA.Common.Shared.DTOs,ActivityDTO.cs:1,15); the aliasesActivityIdentifierTypeandEventIdentifierType(bothint,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:5,8). - Concept introduced, the DTO as the single source of field lengths.
[Rubric §15, Best Practices & Code Quality](assesses whether a rule is declared once and consumed everywhere, or copied) and[Rubric §24, Forms/Validation/UX Safety]. Fiveconst intcaps sit at the top of this record (ActivityDTO.cs:17-30), and the type's own doc comment states why they live here rather than in the domain (ActivityDTO.cs:9-13):Sharedis the lowest project every other layer can reference, so one declaration reaches all of them. Follow the chain:ActivityInvariants.NameMaxLength = ActivityDTO.NameMaxLength(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:15-22) gives the domain check and, through it, the EFHasMaxLengthconfiguration; the Blazor form binds the same constant to both the input cap and the character counter (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activity/ActivityFormFields.razor:21) and to its[MaxLength]data annotation (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Activities/ActivityFormModel.cs:36). A cap can therefore never disagree between the counter a user sees, the invariant that rejects, and the column that truncates. - Concept, the event-local wall-clock DTO.
[Rubric §8, Data Architecture](assesses how time and ownership are modelled at the storage boundary).StartTimeandEndTimeare plainDateTime, notDateTimeOffset, because the entity stores them as wall-clock values in the owning event's IANA time zone and the zone lives once on the event rather than repeated per row (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:28-36). The DTO faithfully carries that decision instead of quietly converting: a consumer that needs an absolute instant has to combine the value with the event's zone, and the public page simply formats it as-is (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Activities/PublicActivityList.razor.cs:44). - Walkthrough: five constants (
ActivityDTO.cs:17-30) then eleven properties (ActivityDTO.cs:33-63).IdandRowVersionare the two framework contracts (lines 33 and 36; the token defaults to an empty array rather than being nullable, seeQuestionDTO).Nameis the onlyrequiredcontent field (line 39);Description(line 42) is optional.StartTimeandEndTime(lines 45-48) are the event-local programme window. The three venue fieldsVenueName,VenueAddress, andVenueUrl(lines 51-57) are all optional and model the off-site case only: an emptyVenueNamemeans the activity happens at the main conference venue, so the public page renders a localized "main venue" label instead of a gap (Activity.cs:38-42,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicActivityList.razor:47), andVenueAddressis what the "directions" affordance hands to a maps URL, labelled with the venue name or the activity name when there is none (PublicActivityList.razor.cs:106-114).SortOrder(line 60) breaks ties between activities that start at the same minute.EventId(line 63) scopes the activity to exactly one event. Note what is absent: the entity's[Navigation] Event?reference (Activity.cs:56-58) is not projected, so an activity response never drags an event graph along with it; and there is no room and no speaker collection, because an activity is deliberately neither a session nor a talk. - Why it's built this way: activities are ADC's own content rather than a Sessionize import, so the
contract is small and mostly optional: an organizer can publish "After party" the moment it is
scheduled and fill in the venue later. Naming the tie-breaker
SortOrder(whereSponsorDTOusesSort) mirrors the underlying entity property in each case rather than imposing a synthetic house name on the wire. - Where it's used: produced by
ActivityDTOMapper, a[Mapper] partial classwhose doc comment records that nothing is redacted because activity data is published to attendees by design (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/DTOs/ActivityDTOMapper.cs:9-17); projected by theIEntityQueryService<TEntity, TEntityDTO, TIdentifierType>injected intoActivitiesController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Activities/ActivitiesController.cs:41), whose anonymous reads are narrowed for non-privileged callers by one overridden read hook,GetReadSpecificationAsync, so an excluded activity is a 404 rather than a redacted record (ActivitiesController.cs:61-79,81-82); rendered byPublicActivityList, which orders byStartTimethenSortOrder(PublicActivityList.razor.cs:88-89), and by the organizer-facingActivityList; written throughActivityCreateRequestandActivityUpdateRequest.
CategoryItemDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:13· Level 1 · record (class)
- What it is: the read-model shape of a
CategoryItem, one selectable option (for example "Beginner" or "Advanced" inside a "Level" category). Carries the item id, display name, sort order, and the FK to its parentConferenceCategoryDTO. - Depends on:
IBaseDTO<TIdentifierType>(fromMMCA.Common.Shared.DTOs,CategoryItemDTO.cs:1,13); the aliasesCategoryItemIdentifierTypeandConferenceCategoryIdentifierType(bothint). - Concept introduced, the entity read DTO (mapped by Mapperly).
[Rubric §4, DDD]and[Rubric §3, Clean Architecture](the read model is separate from the domain entity, so the API surface never leaks the aggregate),[Rubric §9, API & Contract Design](a typed, versionable response shape), and[Rubric §7, Microservices Readiness](it lives inShared, referenceable without Domain). Every Conference entity has a companion DTO built to the same two rules:- It implements
IBaseDTO<TIdentifierType>, the framework's minimal read-model contract: a singlerequired init Idof the entity's id alias (CategoryItemDTO.cs:19, contract atMMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IBaseDTO.cs:9-13). That is the hook the generic query services and controller base classes key on. - It is populated by a Mapperly-generated mapper, not by hand. The companion
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>implementation (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:11-13) is a[Mapper] partial classdeclaringpublic partial CategoryItemDTO MapToDTO(CategoryItem entity);with no body (CategoryItemDTOMapper.cs:16), so the source generator writes the field-by-field copy at compile time (ADR-001): no reflection cost, and a shape mismatch is a build error rather than a runtime surprise. The collection overload is the one hand-written member, a null-guardedSelectover the single map (CategoryItemDTOMapper.cs:19-23).
- It implements
- Walkthrough: one constant and four properties (
CategoryItemDTO.cs:16-28).NameMaxLength = 500(line 16) is the shared capCategoryInvariantsreads back asCategoryItemNameMaxLength(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:23-24), the same single-declaration chainActivityDTOintroduces. ThenId(theIBaseDTOcontract, line 19), therequiredName(line 22), a plainSort(int, display order, line 25), and therequiredCategoryIdFK back to the parent category (line 28).Sortis notrequired, so it defaults to 0 and an item without an explicit order sorts first. Note what is absent: noRowVersion, because a category item is a child entity (AuditableBaseEntity<TIdentifierType>,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:14) edited through its parentCategoryaggregate root, which is where the concurrency token lives. - Why it's built this way: keeping the DTO a flat record with
init-only members makes it an immutable snapshot the query pipeline can project, serialize, and cache without defensive copying; Mapperly keeps the entity to DTO copy allocation-light and drift-proof. - Where it's used: nested inside
ConferenceCategoryDTO.CategoryItems, and referenced by id fromSpeakerCategoryItemDTOand the session tagging DTOs; projected by anIEntityQueryService<TEntity, TEntityDTO, TIdentifierType>and returned byConferenceCategoriesController.
QuestionDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Questions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:14· Level 1 · record (class)
- What it is: the read-model shape of a
Question, a configurable prompt (for example "Dietary requirements" or "T-shirt size") that events, sessions, or speakers can be asked to answer. - Depends on:
IBaseDTO<TIdentifierType>,IConcurrencyAware(both fromMMCA.Common.Shared.DTOs,QuestionDTO.cs:1,14); the aliasQuestionIdentifierType. - Concept introduced, the concurrency-aware DTO (the ETag carrier).
[Rubric §8, Data Architecture](assesses whether optimistic concurrency is carried end to end rather than resolved last-write-wins) and[Rubric §9, API & Contract Design]. On top of the entity-DTO pattern fromCategoryItemDTO, this DTO implementsIConcurrencyAware, contributing one member:byte[] RowVersion(QuestionDTO.cs:32), the SQL Serverrowversiontoken of the version the client just read. Three details are worth reading straight off the interface (MMCA.Common/Source/Core/MMCA.Common.Shared/DTOs/IConcurrencyAware.cs:15-19):- The token travels as an HTTP entity tag, not as a body field on the way back. The API renders it
as the response
ETag, and the client echoes it inIf-Matchon its next write, whereSupportsIfMatchAttributeturns it into the originalRowVersionthe persistence layer compares against (IConcurrencyAware.cs:3-8; ADR-035).ConcurrencyETagis the translator both ends share, and it always emits a weak tag,W/"<base64>", because the same row version renders differently under afields=projection (MMCA.Common/Source/Core/MMCA.Common.Shared/Http/ConcurrencyETag.cs:13-21,27-30,40-45). - It is not optional.
RowVersionis a non-nullablebyte[]defaulted to[], and the interface's remarks state the rule: a DTO read from a persisted aggregate always has a token, and a write that states no precondition is refused with428 Precondition Requiredrather than falling back to last-write-wins (IConcurrencyAware.cs:9-13). Update requests carry no token at all: the precondition travels in the header alone. - The CA1819 suppression that lets a property return
byte[]is declared once on the interface member (IConcurrencyAware.cs:18), not repeated on each DTO, so implementing types stay clean.
- The token travels as an HTTP entity tag, not as a body field on the way back. The API renders it
as the response
- Walkthrough: four constants then eight properties (
QuestionDTO.cs:17-50). The constants (QuestionTextMaxLength = 1000and three 20-character descriptors, lines 17-26) are the capsQuestionInvariantsreads back (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:15-22). ThenId+RowVersion(the two contracts, lines 29 and 32), therequiredQuestionText(line 35), and the optional descriptorsQuestionEntity("session" or "speaker"),QuestionType("text" or "select"),Sort,IsRequired, andQuestionSource("Sessionize" or "User", recording whether the question was imported or added in-app) at lines 38-50. The descriptors are plain nullable strings, not enums: they arrive from Sessionize and the vocabulary is not ADC's to close. - Why it's built this way: carrying
RowVersionon the DTO lets the API stamp anETagon the read and the edit form send back the exact version it saw, so the concurrency check happens at the persistence boundary without the client tracking version state itself; the optional descriptors keep one DTO usable for every question flavor. - Where it's used: mapped by
QuestionDTOMapper(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/DTOs/QuestionDTOMapper.cs:11-16); returned byQuestionsController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Questions/QuestionsController.cs:36) and consumed by the answer-collection UI.QuestionTypeis also the switchGetSessionFeedbackHandlerreads when it splits feedback into ratings and text (GetSessionFeedbackHandler.cs:73).
SessionFeedbackDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:6· Level 1 · record (sealed)
- What it is: the aggregated feedback report for a single session (BR-210,
SessionFeedbackDTO.cs:4): the session identity plus two grouped result sets, numeric ratings and free-text responses. - Depends on:
RatingQuestionSummary,TextQuestionResponses; the aliasesSessionIdentifierTypeandQuestionIdentifierType(bothint). - Concept, the composed query-projection report.
[Rubric §6, CQRS & Event-Driven](a read model purpose-built for one query rather than a mapped entity) and[Rubric §12, Performance & Scalability]. This is the parent that composes the two Level-0 records above. It does not implementIBaseDTO<TIdentifierType>: it is not a CRUD read model but a computed report, so it is assembled by a handler rather than a Mapperly mapper. - Walkthrough: four
required initmembers (SessionFeedbackDTO.cs:9-18),SessionIdandSessionTitle(the report header),Ratings(anIReadOnlyList<RatingQuestionSummary>, one entry per rating question) andTextResponses(anIReadOnlyList<TextQuestionResponses>, one entry per non-rating question). Splitting ratings from text mirrors the two answer kinds a session collects. The producing handler shows how the shape is filled and where it refuses: it loads the session with itsSessionSpeakersandSessionQuestionAnswersuntracked, returnsNotFoundwhen the session is gone, returns aForbiddenerror codedSpeaker.NotAssignedif the requested speaker is not assigned to that session, returns an empty-but-valid report when there are no answers, then loads only the questions that actually have answers and routes each answer group toRatingsorTextResponses(GetSessionFeedbackHandler.cs:23-53,56-101). - Why it's built this way: pre-aggregating on the server (averages and groupings) keeps the speaker
UI a thin renderer and avoids shipping every raw answer row to the client; returning an empty report
rather than a 404 when nobody answered keeps the dashboard's happy path free of special cases; and
loading only the questions referenced by an answer (
GetSessionFeedbackHandler.cs:56-63) keeps the second query proportional to the feedback actually received. - Where it's used: returned by
GET /Speakers/{speakerId}/sessions/{sessionId}/feedback(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:411-436) viaGetSessionFeedbackHandler; fetched bySpeakerDashboardService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Speakers/SpeakerDashboardService.cs:73-79) and rendered on the speaker dashboard.[Rubric §11, Security]is worth reading off that endpoint directly: it is[Authorize]and applies a self-or-organizer gate in the action body, requiring either theOrganizerrole or aspeaker_idclaim matching the route speaker before it calls the handler (SpeakersController.cs:418,424-427). Its own doc comment records why it carries no output cache: free text comments are the speaker's own read, and every response is authorization-dependent, so a shared public cache entry would be a leak (SpeakersController.cs:411-416). - Caveats / not-in-source: the answers this report aggregates are the Conference module's
SessionQuestionAnswers, not the Engagement module'sSessionFeedbackaggregate; nothing in this DTO or its handler reads across that module boundary.
SpeakerCategoryItemDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerCategoryItemDTO.cs:8· Level 1 · record (class)
- What it is: the read-model shape of the
SpeakerCategoryItemjoin row, the many-to-many link that attaches aCategoryItem(a topic or a locality tier) to aSpeaker. - Depends on:
IBaseDTO<TIdentifierType>; the aliasesSpeakerCategoryItemIdentifierType(int),SpeakerIdentifierType(System.Guid), andCategoryItemIdentifierType(int). - Concept: the entity read DTO (see
CategoryItemDTO), here for a join entity: a flat record of foreign keys with no editable content of its own, hence no length constants. - Walkthrough: three
required initmembers (SpeakerCategoryItemDTO.cs:11-17),Id(theIBaseDTOcontract),SpeakerId(parent FK), andCategoryItemId(the linked item). No concurrency token: a bare join row is add or remove only, so there is nothing to update optimistically. - Why it's built this way: modeling the speaker-to-category-item relationship as an explicit join DTO (rather than an inline id list) keeps the child collection uniform with every other Conference join and lets the mapper project it like any other entity.
- Where it's used: nested in
SpeakerDTO.SpeakerCategoryItems; mapped bySpeakerCategoryItemDTOMapper(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerCategoryItemDTOMapper.cs:11-16), whichSpeakerDTOMappertakes as a constructor dependency and marks[UseMapper]so the generator uses it for the children (SpeakerDTOMapper.cs:18,23-24).
SpeakerQuestionAnswerDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerQuestionAnswerDTO.cs:9· Level 1 · record (class)
- What it is: the read-model shape of a speaker-level question answer, binding a
Speakerto aQuestiontogether with the speaker's answer value. - Depends on:
IBaseDTO<TIdentifierType>; the aliasesSpeakerQuestionAnswerIdentifierType,SpeakerIdentifierType,QuestionIdentifierType. - Concept: the entity read DTO (see
CategoryItemDTO). Unlike the bareSpeakerCategoryItemDTOjoin, this one carries a payload, the answer text, so it is a link plus a value. - Walkthrough: four
required initmembers (SpeakerQuestionAnswerDTO.cs:12-21),Id,SpeakerId(parent FK),QuestionId(the answered question), andAnswerValue(the response, stored as a string regardless of the question's declaredQuestionDTO.QuestionType). - Why it's built this way: keeping the answer as a flat
string AnswerValuelets one DTO carry any question type's answer (free text, a selected option, a numeric rating) without a type-specific shape; the cost is that consumers parse, which is exactly what the feedback handler does when it averages ratings (GetSessionFeedbackHandler.cs:75-79). - Where it's used: nested in
SpeakerDTO.SpeakerQuestionAnswers; mapped bySpeakerQuestionAnswerDTOMapper(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerQuestionAnswerDTOMapper.cs:11-16), itself a[UseMapper]dependency ofSpeakerDTOMapper(SpeakerDTOMapper.cs:19,26-27).
SponsorDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sponsors·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:15· Level 1 · record (class)
- What it is: the read-model shape of the
Sponsoraggregate root: display name,SponsorTier, branding links, the owning event's id, and the optional expo-floor booth details. - Depends on:
IBaseDTO<TIdentifierType>,IConcurrencyAware(SponsorDTO.cs:1,15),SponsorTier; the aliasesSponsorIdentifierType(int) andEventIdentifierType. - Concept, the DTO that drops the navigation.
[Rubric §9, API & Contract Design]and[Rubric §3, Clean Architecture]. Structurally this is the concurrency-aware entity DTO already introduced byQuestionDTO, but it is the cleanest illustration of what a DTO deliberately leaves behind. TheSponsorentity carries both anEventIdscalar and a[Navigation] Event?reference used for public-visibility filtering (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:44-49); the DTO keeps only the scalarEventId(SponsorDTO.cs:69). The parent event never rides along, so a sponsor response cannot accidentally serialize an entire event graph.[Rubric §11, Security]shows up by contrast withSpeakerDTO: the sponsor mapper redacts nothing, and says why in its own doc comment, sponsor data is bought placement and therefore public (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:9-11). It is also the fullest example of the shared-constant chain fromActivityDTO: seven caps declared here (SponsorDTO.cs:17-36) are consumed bySponsorInvariants(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:15-31), by EF through those invariants (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sponsors/SponsorConfiguration.cs:20-46), and by the sponsor form for both its input cap and its[MaxLength]annotation (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsor/SponsorFormFields.razor:20,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Sponsors/SponsorFormModel.cs:36). - Walkthrough: seven constants then thirteen properties (
SponsorDTO.cs:17-75).Id+RowVersionare the two contracts (lines 39 and 42). TherequiredName(line 45) is the only mandatory content field.Tier(line 48) is the enum that drives public ordering.LogoUrl,Description,WebsiteUrl,LinkedInUrl, andTwitterHandle(lines 51-63) are all optional branding, typed as plain nullable strings rather thanUribecause they are operator-entered.Sort(line 66) is the tie-breaker within a tier.EventId(line 69) scopes the sponsor to exactly one event.IsExhibitor+BoothNumber(lines 72-75) model the expo floor; the domain keeps a stored booth number even when the flag is false, because the flag drives display and does not reject stored data (Sponsor.cs:54-58). - Why it's built this way: sponsors are sold rather than imported, so unlike the Sessionize-sourced entities this contract is fully ADC's own: a closed enum for tier, a required name, everything else optional so an organizer can create a sponsor the moment a deal closes and fill in the logo later.
- Where it's used: mapped by
SponsorDTOMapper(SponsorDTOMapper.cs:12-17); projected by theIEntityQueryService<TEntity, TEntityDTO, TIdentifierType>injected intoSponsorsController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:41), where the one overriddenGetReadSpecificationAsynchook narrows every anonymous read to sponsors of published events (SponsorsController.cs:61-79); rendered byPublicSponsorListand the organizer-facingSponsorList.
CategoryItemChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Categories.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/DomainEvents/CategoryItemChanged.cs:13· Level 2 · record (sealed)
- What it is: the domain event a
Categoryaggregate raises when one of itsCategoryItemchildren is added, updated, or removed. It carries the parent category id, the child item id, and the item's display name. - Depends on:
BaseDomainEvent(Level 1),DomainEntityState(Level 0), both fromMMCA.Common.Domain(CategoryItemChanged.cs:1-2); the aliasesConferenceCategoryIdentifierTypeandCategoryItemIdentifierType. - Concept introduced, the child-change domain event.
[Rubric §6, CQRS & Event-Driven](assesses whether state changes are expressed as typed, first-class events that typed handlers can subscribe to) and[Rubric §4, DDD](domain events are part of the ubiquitous language: an aggregate announces what happened inside its boundary). Every Conference child or join entity has a companionChangedrecord, and two design choices visible here are reused across that whole family:- It derives from
BaseDomainEventdirectly, not fromEntityChangedEvent<TIdentifierType>.EntityChangedEvent<T>models a single entity id (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:24-27); a child change needs two identifiers (the parent aggregate and the child) plus a descriptor, so it does not fit that one-id shape. The aggregate-root lifecycle events,CategoryChangedand its siblings, do useEntityChangedEvent<T>. - It is a
sealed record classwith no behavior. The inheritedDateOccurredandMessageIdcome fromBaseDomainEvent, each defaulted at construction (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:26-35); the type exists purely soIDomainEventHandler<CategoryItemChanged>can be registered and dispatched independently of every other event type. Being arecordgives it structural equality, but the base's own remarks warn that this is not a deduplication mechanism: two logically identical events raised separately are never equal because both defaults are fresh per instance, and consumer-side dedup is the inbox's job keyed onMessageId(BaseDomainEvent.cs:9-17, ADR-021).
- It derives from
- Walkthrough: four positional members (
CategoryItemChanged.cs:13-17),State(theAdded/Updated/Deletedtransition),CategoryId(the parent),CategoryItemId(the child), andName(the item's display name, so a handler or log line has a human-readable label without re-loading the entity). - Why it's built this way: keeping the payload flat and self-describing (ids plus a name) means a downstream handler never has to re-query the aggregate to act, and the event survives serialization through the dispatch pipeline unchanged.
- Where it's used: raised by
Categoryat all three child mutation points,AddCategoryItem,UpdateCategoryItem, andRemoveCategoryItem(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:144,176,194); collected on the aggregate and dispatched in-process byDomainEventDispatcherafterSaveChangesAsync. - Caveats / not-in-source: no handler subscribes to it in the ADC source today; it is available for
future observers and audit. Note also the one asymmetry: deleting the parent category cascade
soft-deletes its items (BR-71) through a single
DeleteChildrencall but raises oneCategoryChanged(Deleted)rather than oneCategoryItemChangedper item (Category.cs:102-114), so a subscriber must treat parent deletion as implying its children.
ConferenceCategoryDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:14· Level 2 · record (class)
- What it is: the read-model shape of a
Categoryaggregate root (for example "Level", "Track", or "Session format"), including its childCategoryItemDTOoptions. - Depends on:
IBaseDTO<TIdentifierType>,IConcurrencyAware,CategoryItemDTO(Level 1); the aliasConferenceCategoryIdentifierType. - Concept, the aggregate-root read DTO with a child collection.
[Rubric §9, API & Contract Design]and[Rubric §8, Data Architecture]. This combines both patterns seen above: it is a concurrency-aware DTO (RowVersion, as inQuestionDTO) and it nests a child collection ofCategoryItemDTO, so the whole aggregate (category plus its options) serializes in one response. The concurrency token sits here and not on the child, which is the aggregate boundary showing through the read model: you version the root, not each option. The entity declarations line up with that split,Categoryis anAuditableAggregateRootEntity<TIdentifierType>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:16) whileCategoryItemis a plainAuditableBaseEntity<TIdentifierType>(CategoryItem.cs:14). - Walkthrough: two constants then six properties (
ConferenceCategoryDTO.cs:17-38).TitleMaxLength = 255andTypeMaxLength = 100(lines 17-20) are the capsCategoryInvariantsreads back (CategoryInvariants.cs:17-21) and that EF then applies asHasMaxLength(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Categories/ConferenceCategoryConfiguration.cs:27,34). ThenId+RowVersion(the contracts, lines 23 and 26), therequiredTitle(line 29), a plainSort(line 32) and an optionalType("session" or "speaker", line 35), and theCategoryItemscollection, anIReadOnlyCollection<CategoryItemDTO>initialized to[](ConferenceCategoryDTO.cs:38) so it is never null even when the category has no items yet. - Why it's built this way: defaulting the child collection to an empty collection literal removes null checks downstream; nesting the items lets the categories UI render an editable category-with-options block from a single fetch.
- Where it's used: mapped by
ConferenceCategoryDTOMapper, which takesCategoryItemDTOMapperas a constructor dependency and marks it[UseMapper]so the generator uses it for the children (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:12-21); returned byConferenceCategoriesController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:37) and consumed by the category-management UI.
SpeakerDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:18· Level 2 · record (class)
- What it is: the read-model shape of the
Speakeraggregate root: profile fields, social links, an optional link to an Identity user, and two child collections (category items and question answers). It is the richest DTO in this unit. - Depends on:
IBaseDTO<TIdentifierType>,IConcurrencyAware,SpeakerCategoryItemDTO,SpeakerQuestionAnswerDTO; the aliasesSpeakerIdentifierType(aSystem.Guid, because speakers are imported with Sessionize-assigned identity per BR-61,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3,19) andUserIdentifierType(anint, owned by Identity). - Concept, the cross-context read DTO and the redacting mapper.
[Rubric §7, Microservices Readiness],[Rubric §8, Data Architecture],[Rubric §11, Security]. Three things make this DTO worth studying beyond its size:LinkedUserIdis a bare nullable scalar (SpeakerDTO.cs:97), not a nested user object and not an EF navigation, because the user and the speaker live in separate databases (ADR-006). The link is reconciled by events (SpeakerLinkedToUserandSpeakerUnlinkedFromUser), never by a cross-database join.Emailis nullable on the DTO although the entity holds anEmailvalue object, becauseSpeakerDTOMapperredacts it: the publicMapToDTOcalls the generatedMapToDTOGeneratedand then returnsdto with { Email = null }unless the caller is in theOrganizerrole (BR-66,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:13,30-37,46). A small private converter,NullableEmailToString(SpeakerDTOMapper.cs:49), is what lets the generator flatten theEmailvalue object to a string in the first place. The redaction is in the mapper rather than the controller, so every read path inherits it. This is the DTO layer doing real work, not just shape translation.- One of its ten length constants is deliberately UI-only.
BioMaxLength = 4000(SpeakerDTO.cs:32-37) is documented as an input cap and character counter and nothing else:Biocarries no EF length and no domain invariant, so a Sessionize-imported bio longer than the counter still persists intact. The nine other caps do flow intoSpeakerInvariants(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:15-31), the same chainActivityDTOintroduces. Reading the exception in the source is the point: the pattern is a convention, not a law, and the deviation is written down where it happens.
- Walkthrough: ten constants (
SpeakerDTO.cs:21-52) then seventeen properties (SpeakerDTO.cs:55-103).Id+RowVersionare the contracts (lines 55 and 58). Therequiredname fields areFirstName,LastName, andFullName(lines 61-67);FullNameis a computed expression on the entity (Speaker.cs:61) flattened into a stored string here, so a client renders a display name without concatenating. Then the optional profile fields,Email(line 70, the redacted one),Bio,TagLine,ProfilePicture, theIsTopSpeakerflag, and the social handlesTwitterHandle,LinkedInUrl,GitHubUrl,WebsiteUrl(lines 73-94, plain nullable strings because they come through the Sessionize import).LinkedUserId(line 97) is the cross-context link. The two child collectionsSpeakerCategoryItemsandSpeakerQuestionAnswers(lines 100-103) are bothIReadOnlyCollection<...>defaulted to[], and both are filled by[UseMapper]child mappers rather than by hand (SpeakerDTOMapper.cs:23-27). - Why it's built this way: denormalizing
FullNameand defaulting both collections keeps the speaker UI a thin renderer; exposingLinkedUserIdas a bare nullable id is exactly the database-per-service posture, since the Conference read model knows the id of the linked user but never reaches across the boundary to fetch it; and redactingEmailin the mapper means the PII rule cannot be forgotten by a new endpoint. - Where it's used: projected by the
IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>injected intoSpeakersController(SpeakersController.cs:48), and returned by its create and update commands; the update path is the concurrency story end to end, an[Authorize]self-or-organizer action marked[SupportsIfMatch]that pulls the required token out of the request header and hands it to the command (SpeakersController.cs:327-352, ADR-035). Rendered by the public speaker pages and bySpeakerDashboardService.
CategoryChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Categories.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/DomainEvents/CategoryChanged.cs:12· Level 3 · record (sealed)
- What it is: the aggregate-lifecycle event a
Categoryraises when it is created, updated, or deleted. Carries the category id and its title. - Depends on:
EntityChangedEvent<TIdentifierType>(Level 2),DomainEntityState(Level 0); the aliasConferenceCategoryIdentifierType. - Concept introduced, the aggregate-root lifecycle event.
[Rubric §6, CQRS & Event-Driven]and[Rubric §15, Best Practices & Code Quality](one event type per aggregate instead of a separateCreated/Updated/Deletedtrio). Where the Level-2 events above derive fromBaseDomainEventdirectly, the root-level events derive fromEntityChangedEvent<TIdentifierType>, which consolidates the CRUD-lifecycle pattern: it holdsStateplus a single genericEntityId(constrainednotnull,MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:24-27), and each concrete record passes its own id up to that base (CategoryChanged.cs:16:: EntityChangedEvent<ConferenceCategoryIdentifierType>(State, CategoryId)). A subtle but real consequence: the derived record re-exposes the id under a domain-meaningful name (CategoryId) while the same value is also reachable as the inherited genericEntityId, one identity under two property names, so handlers written againstEntityChangedEvent<T>and handlers written against the concrete type both work. The base's own doc comment draws the dividing line, generic CRUD lifecycle belongs here while a business transition such asOrderPaidkeeps inheritingBaseDomainEventdirectly (EntityChangedEvent.cs:15-19), and it also fixes the raise convention:Addedfrom factory methods,Updatedfrom mutators,DeletedfromDelete()(EntityChangedEvent.cs:9-14). - Walkthrough: three positional members (
CategoryChanged.cs:13-15),State,CategoryId, andTitle;StateandCategoryIdare forwarded to the base constructor (line 16), andTitleis the record's own added property, the human-readable descriptor a handler or log line can use without re-loading the aggregate. - Why it's built this way: one lifecycle event per aggregate keeps the event surface small, and
Statelets a handler branch on the transition rather than subscribing to three separate types. - Where it's used: raised from
Category'sCreate(Added),Update(Updated), andDelete(Deleted) atCategory.cs:72,95,111, where the delete only raises once the combined cascade result succeeds (Category.cs:106-111); dispatched in-process byDomainEventDispatcherafterSaveChangesAsync. - Caveats / not-in-source: like
CategoryItemChanged, no handler subscribes to it in the ADC source today. It is an in-process domain event, not an integration event, so it never reaches the outbox or the broker on its own.
EventQuestionAnswerChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/EventQuestionAnswerChanged.cs:13· Level 2 · record (sealed)
- What it is: the domain event an
Eventaggregate raises when one of itsEventQuestionAnswerchildren is added, updated, or removed. It announces a change inside the aggregate boundary, not a change of the aggregate root itself. - Depends on:
BaseDomainEvent(the base record) and theDomainEntityStateenum, both fromMMCA.Common.Domain(EventQuestionAnswerChanged.cs:1-2); the module identifier aliasesEventIdentifierType,EventQuestionAnswerIdentifierType, andQuestionIdentifierType, allintbehind aglobal using(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8,:9,:11, see the primer). No NuGet dependency. - Concept introduced, the child-change domain event.
[Rubric §6, CQRS & Event-Driven](assesses whether state transitions are published as typed, first-class events that typed handlers can subscribe to, instead of leaking out as ad-hoc side effects) and[Rubric §4, DDD](assesses whether the aggregate root is the sole author of change inside its consistency boundary and names that change in the ubiquitous language). Every child and join entity in the Conference model has a companion...Changedrecord, and this family shares three design choices worth learning once:- It derives from
BaseDomainEventdirectly, not fromEntityChangedEvent<TIdentifierType>. That base models exactly one identifier (Stateplus a genericEntityId,MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:24-27), which a child change cannot fit: it needs the parent id and the child id, plus a descriptor. The aggregate-root lifecycle events later in this part (EventChangedand siblings) do use it. - It is a
sealed record classwith no behavior. Structural equality is free, and the inheritedMessageId/DateOccurredcome from the base (MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseDomainEvent.cs:28-35). The type exists so anIDomainEventHandler<in TDomainEvent>closed overEventQuestionAnswerChangedcan be registered and dispatched independently of every other event type. - The payload is flat ids plus a descriptor. The event must survive being serialized into an
OutboxMessagerow in the same transaction as the data (ADR-003), so it carries no entity references. How a raised event reaches the outbox and the in-process handlers is taught once in Group 04; this part only produces them.
- It derives from
- Walkthrough: four positional members
(
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/EventQuestionAnswerChanged.cs:13-17):State(theAdded/Updated/Deletedtransition),EventId(the parent aggregate),EventQuestionAnswerId(the child row), andQuestionId(line 17), the FK to theQuestionthat was answered, so a handler knows which question the answer belongs to without re-loading the aggregate. - Why it's built this way: publishing ids rather than the entity keeps the event a self-describing, serializable fact and keeps a subscriber out of the aggregate's internals. Raising one event per child type (rather than a single generic "event updated") lets cache invalidation and projections target exactly what moved.
- Where it's used: raised by
Event'sAddEventQuestionAnswer(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:619, raises at:605),UpdateEventQuestionAnswer(:616, raises at:629), andRemoveEventQuestionAnswer(:639, raises at:647). Every raised event is written to an outbox row by the save-changes interceptor, which adds a row for every domain event and routes only the non-integration ones to in-process dispatch (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:242-260), whereDomainEventDispatcherdelivers them. - Caveats / not-in-source: no
IDomainEventHandlersubscribes to it today. In fact the Conference Application layer contains exactly one domain event handler,SpeakerDeletedHandlerforSpeakerChanged; every other event in this part is raised, persisted, and dispatched with no subscriber. That is a deliberate cost: the contract exists so a consumer can be added without touching the aggregate.
EventSpeakerChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/EventSpeakerChanged.cs:13· Level 2 · record (sealed)
- What it is: the change event an
Eventraises when anEventSpeakerjoin entity is added or removed, that is, when aSpeakeris attached to or detached from the event. - Depends on:
BaseDomainEvent,DomainEntityState; aliasesEventIdentifierType,EventSpeakerIdentifierType,SpeakerIdentifierType(the last isSystem.Guid, notint, because speakers carry Sessionize-assigned GUIDs per BR-61,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19). - Concept: the child-change domain event introduced by
EventQuestionAnswerChanged, here for a join entity.[Rubric §6, CQRS & Event-Driven]. The XML doc says "added or removed" with no update case (EventSpeakerChanged.cs:7): a pure FK-pair join carries no editable content, so only two transitions are meaningful. The parameter type staysDomainEntityState(nothing narrows it structurally), and the raise sites use onlyAddedandDeleted. - Walkthrough:
State,EventId(parent),EventSpeakerId(the join row),SpeakerId(the linked speaker), lines 14-17. - Where it's used: raised by
Event'sAddEventSpeaker(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:536, raises at:530),RestoreEventSpeaker(:546, raises at:555), andRemoveEventSpeaker(:565, raises at:573). Note the restore path: un-deleting a soft-deleted join row raisesAddedagain (:555), so a subscriber sees the same transition it saw the first time and needs no separate "restored" case. - Caveats / not-in-source: no handler subscribes today (see
EventQuestionAnswerChanged).
RoomChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/RoomChanged.cs:13· Level 2 · record (sealed)
- What it is: the child-change event an
Eventraises when one of itsRoomchildren is added, updated, or removed. Carries the parent event id, the room id, and the room name. - Depends on:
BaseDomainEvent,DomainEntityState; aliasesEventIdentifierType,RoomIdentifierType. - Concept: the child-change domain event (see
EventQuestionAnswerChanged).[Rubric §6, CQRS & Event-Driven]and[Rubric §13, Observability & Operability](assesses whether the system emits structured, correlatable signal about what it did). The instructive detail here is the descriptor choice: the fourth member isRoomName, a display label, not another foreign key (RoomChanged.cs:17). Every other child event in this family carries an FK as its fourth member. A label makes the event readable on its own, so a log line or a projection can render the room by name with no reload; an FK would force the subscriber back into the database. Which of the two a...Changedrecord carries is therefore a real contract decision, not boilerplate. - Walkthrough:
State,EventId(parent),RoomId(child),RoomName(display label), lines 14-17. - Where it's used: raised by
Event'sAddRoom(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:385, raises at:379),UpdateRoom(:395, raises at:417),RestoreRoom(:438, raisesAddedat:474), andRemoveRoom(:484, raises at:491). - Caveats / not-in-source: no handler subscribes today. The self-describing payload is there for a subscriber that does not yet exist.
SessionCategoryItemChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/DomainEvents/SessionCategoryItemChanged.cs:13· Level 2 · record (sealed)
- What it is: the domain event a
Sessionraises when one of itsSessionCategoryItemjoin rows is added or removed (the tags, track, and level assignments on a session). - Depends on:
BaseDomainEvent,DomainEntityState; aliasesSessionIdentifierType,SessionCategoryItemIdentifierType,CategoryItemIdentifierType. - Concept: the child-change domain event (
EventQuestionAnswerChanged), applied to theSessionaggregate.[Rubric §6, CQRS & Event-Driven]and[Rubric §4, DDD]. It carries three ids so a handler can react without reloading: the parent session, the join row, and theCategoryItemthat was linked. - Walkthrough:
sealed record classwithState,SessionId,SessionCategoryItemId,CategoryItemId(SessionCategoryItemChanged.cs:13-17). Being a record, immutability and structural equality come for free; the primary-constructor parameters are the only state. - Where it's used: raised by
Session'sAddSessionCategoryItem(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:397, raises at:418),RestoreSessionCategoryItem(:435, raisesAddedat:447), andRemoveSessionCategoryItem(:457, raises at:465); captured by the outbox inSaveChangesAsyncand dispatched in-process. - Caveats / not-in-source: no handler subscribes today.
SessionQuestionAnswerChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/DomainEvents/SessionQuestionAnswerChanged.cs:13· Level 2 · record (sealed)
- What it is: the domain event a
Sessionraises when aSessionQuestionAnswerchild row is added, updated, or removed. Same shape asSessionCategoryItemChanged, for the answer child rather than the category join. - Depends on:
BaseDomainEvent,DomainEntityState; aliasesSessionIdentifierType,SessionQuestionAnswerIdentifierType,QuestionIdentifierType. - Concept: the child-change domain event (
EventQuestionAnswerChanged).[Rubric §6, CQRS & Event-Driven]. The behavioral difference against a join event is theUpdatedstate: an answer's value can change in place (a join row cannot), soUpdateSessionQuestionAnswerexists (Session.cs:508) and the raise sites use all three transitions. - Walkthrough:
sealed record classwithState,SessionId,SessionQuestionAnswerId,QuestionId(SessionQuestionAnswerChanged.cs:13-17). - Where it's used: raised by
Session'sAddSessionQuestionAnswer(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:484, raises at:497),UpdateSessionQuestionAnswer(:508, raises at:521), andRemoveSessionQuestionAnswer(:531, raises at:539); captured by the outbox. Do not confuse it withSessionFeedbackSubmitted, the cross-module event the application layer raises alongside the same create path: the domain event says "a row changed", the integration event says "an attendee gave feedback". - Caveats / not-in-source: no handler subscribes today.
SessionSpeakerChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/DomainEvents/SessionSpeakerChanged.cs:13· Level 2 · record (sealed)
- What it is: the domain event a
Sessionraises when aSessionSpeakerjoin row (the session-to-speaker association) is added or removed. - Depends on:
BaseDomainEvent,DomainEntityState; aliasesSessionIdentifierType,SessionSpeakerIdentifierType,SpeakerIdentifierType. - Concept: the child-change domain event (
EventQuestionAnswerChanged), join-entity flavor as inEventSpeakerChanged.[Rubric §6, CQRS & Event-Driven]. The XML doc records "added or removed" (SessionSpeakerChanged.cs:7), and the raise sites use onlyAddedandDeleted. - Walkthrough:
sealed record classwithState,SessionId,SessionSpeakerId,SpeakerId(SessionSpeakerChanged.cs:13-17). - Where it's used: raised by
Session'sAddSessionSpeaker(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:315, raises at:336),RestoreSessionSpeaker(:352, raisesAddedat:361), andRemoveSessionSpeaker(:371, raises at:379); captured by the outbox. - Caveats / not-in-source: no handler subscribes today.
SpeakerCategoryItemChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/DomainEvents/SpeakerCategoryItemChanged.cs:13· Level 2 · record (sealed)
- What it is: the
Speaker-side twin ofSessionCategoryItemChanged: raised when aSpeakerCategoryItemjoin row is added or removed from a speaker (for example the speaker's topic or locality tags). - Depends on:
BaseDomainEvent,DomainEntityState; aliasesSpeakerIdentifierType,SpeakerCategoryItemIdentifierType,CategoryItemIdentifierType. - Concept: the child-change domain event (
EventQuestionAnswerChanged).[Rubric §6, CQRS & Event-Driven]. Structurally identical to the session variant with the parent id swapped from session to speaker, which is exactly the point of the family: one shape, one id triple, one type per relationship so handlers stay narrow. - Walkthrough:
sealed record classwithState,SpeakerId,SpeakerCategoryItemId,CategoryItemId(SpeakerCategoryItemChanged.cs:13-17). - Where it's used: raised by
Speaker'sAddSpeakerCategoryItem(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:314, raises at:335),RestoreSpeakerCategoryItem(:352, raisesAddedat:364), andRemoveSpeakerCategoryItem(:374, raises at:382); captured by the outbox. - Caveats / not-in-source: no handler subscribes today.
SpeakerQuestionAnswerChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/DomainEvents/SpeakerQuestionAnswerChanged.cs:13· Level 2 · record (sealed)
- What it is: the
Speaker-side twin ofSessionQuestionAnswerChanged: raised when aSpeakerQuestionAnswerchild row is added, updated, or removed. - Depends on:
BaseDomainEvent,DomainEntityState; aliasesSpeakerIdentifierType,SpeakerQuestionAnswerIdentifierType,QuestionIdentifierType. - Concept: the child-change domain event (
EventQuestionAnswerChanged).[Rubric §6, CQRS & Event-Driven]. As with the session answer, the answer value is mutable, so the raise sites spanAdded,Updated, andDeleted. - Walkthrough:
sealed record classwithState,SpeakerId,SpeakerQuestionAnswerId,QuestionId(SpeakerQuestionAnswerChanged.cs:13-17). - Where it's used: raised by
Speaker'sAddSpeakerQuestionAnswer(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:401, raises at:414),UpdateSpeakerQuestionAnswer(:425, raises at:438), andRemoveSpeakerQuestionAnswer(:448, raises at:456); captured by the outbox. - Caveats / not-in-source: no handler subscribes today.
ActivityChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Activities.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/DomainEvents/ActivityChanged.cs:12· Level 3 · record (sealed)
- What it is: the aggregate-root lifecycle event for an
Activity, the non-session agenda item (a keynote reception, a lunch break, a hallway track slot): raised when one is created, updated, or soft-deleted. It carries the activity id and its display name. - Depends on:
EntityChangedEvent<TIdentifierType>(the base record),DomainEntityState; aliasActivityIdentifierType. - Concept: the aggregate-root lifecycle event, taught in detail under
EventChangedbelow.[Rubric §6, CQRS & Event-Driven]and[Rubric §15, Best Practices & Code Quality](assesses whether a recurring shape is factored once instead of copied). The instructive detail is a contrast:Activityowns anEventIdproperty (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:54), yetActivityChangeddoes not carry it, where the structurally similarSessionChangeddoes. A subscriber that needs the parent event for an activity therefore has to reload it, which is a real (if small) asymmetry in the event contracts of this bounded context rather than a rule you can infer. - Walkthrough: three positional members (
ActivityChanged.cs:12-16):State,ActivityId, andName, with(State, ActivityId)forwarded toEntityChangedEvent<ActivityIdentifierType>on line 16. - Where it's used: raised from
Activity'sCreatefactory (declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:99, raises at:127), itsUpdatemethod (:145, raises at:173), and itsDeleteoverride, which calls the base soft-delete first and raises the event only when that base call returned success (:180-188, raise at:185); dispatched in-process. - Caveats / not-in-source: no
IDomainEventHandler<ActivityChanged>is implemented today; the event is raised and persisted to the outbox regardless.
EventChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/EventChanged.cs:12· Level 3 · record (sealed)
- What it is: the aggregate-root lifecycle event an
Eventraises when it is created, updated, or deleted. Publish and unpublish also flip state and emit it withUpdated. It carries the event id and name. - Depends on:
EntityChangedEvent<TIdentifierType>(the base record),DomainEntityState; aliasEventIdentifierType. - Concept introduced, the aggregate-root lifecycle event.
[Rubric §6, CQRS & Event-Driven](assesses whether the write model announces its transitions as consumable events) and[Rubric §15, Best Practices & Code Quality](assesses whether a recurring shape is factored once instead of copied: one event type per aggregate rather than a separateCreated/Updated/Deletedtrio). Where the Level-2 events above derive fromBaseDomainEventdirectly, the root-level events derive fromEntityChangedEvent<TIdentifierType>, which consolidates the CRUD lifecycle intoStateplus a single genericEntityId(MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/EntityChangedEvent.cs:24-27), and each concrete record forwards its own id to that base (line 16:: EntityChangedEvent<EventIdentifierType>(State, EventId)). A subtle but real consequence: the derived record re-exposes the identity under a domain-meaningful name (EventId) while the same value is also reachable as the inheritedEntityId, so a handler written againstEntityChangedEvent<T>and one written against the concrete type both work. The base's own doc is explicit that this shape is for generic CRUD lifecycle only: a business transition with a unique payload should derive straight fromBaseDomainEvent(EntityChangedEvent.cs:15-19). - Walkthrough: three positional members
(
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/DomainEvents/EventChanged.cs:12-16):State,EventId, andName; the first two are forwarded to the base constructor on line 16,Nameis the record's own added property and exists so a log line or cache-invalidation handler has a human-readable label without a reload. - Why it's built this way: one lifecycle event per aggregate keeps the event surface small and lets a
subscriber branch on
Staterather than subscribing to three separate types. Because these records are serialized through the outbox, keeping the base shared also keeps their contract shape stable (ADR-010 governs the versioning rules for anything that crosses a boundary). - Where it's used: raised from
Event'sCreate(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:170, raises at:207),Update(:229, raises at:265),Publish(:272, raises at:285),Unpublish(:292, raises at:305), andDelete(:328, raises at:340); dispatched in-process byDomainEventDispatcherafterSaveChangesAsync. Note that publish and unpublish reuse theUpdatedtransition rather than introducing dedicated event types, which is the CRUD-lifecycle base doing its job: a subscriber that cares specifically about publication has to compare theEvent's own state, not the event type. - Caveats / not-in-source: no handler subscribes today.
EventFeedbackSubmitted
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events.IntegrationEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/IntegrationEvents/EventFeedbackSubmitted.cs:20· Level 3 · record (sealed)
What it is: the cross-module integration event Conference raises when an attendee submits feedback on an event. The Engagement module subscribes and awards the attendee points for the feedback.
Depends on:
BaseIntegrationEvent(the base record) andEventNameAttribute(applied on line 19); aliasesUserIdentifierType,EventIdentifierType; BCLDateTime.Concept introduced, the integration event (as distinct from the domain event).
[Rubric §7, Microservices Readiness](assesses whether cross-module coupling runs through a published contract a peer can consume without a code reference back into the producer's domain) and[Rubric §9, API & Contract Design](the async message is a public contract, versioned like one). Three things separate it from every event above.- It derives from
BaseIntegrationEvent, which adds a virtualSchemaVersiondefaulting to1(MMCA.Common/Source/Core/MMCA.Common.Domain/DomainEvents/BaseIntegrationEvent.cs:32) and implementsIIntegrationEvent, the marker the save-changes interceptor branches on: an integration event still gets an outbox row, but it is deliberately not dispatched in process, so its row stays unprocessed (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Interceptors/DomainEventSaveChangesInterceptor.cs:244-257) and theOutboxProcessorpublishes it throughIMessageBusinstead, wrapped in a broker-publish resilience pipeline (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Outbox/Processing/OutboxProcessor.cs:589-602). The registered transport then decides delivery: in-process for the monolith, MassTransit broker for the extracted services. - It carries an explicit wire name.
[EventName("Conference.EventFeedbackSubmitted.v1")](EventFeedbackSubmitted.cs:19) pins the serialized message-type name, so renaming the C# record does not silently break an already-deployed consumer. - It lives in the
.Sharedproject, not.Domain, precisely so a subscribing module can reference the contract without pulling in Conference's domain model.
ADR-010 is the rule for evolving it: additive changes keep the version, a breaking change means a new type plus a consumer-side upcaster registered per ADR-090.
- It derives from
Walkthrough: three positional members (
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/IntegrationEvents/EventFeedbackSubmitted.cs:20-24):UserId(the attendee, line 21),EventId(the subject, line 22), andSubmittedOnUtc(when the answer was recorded, in UTC, line 23). The producer supplies that instant from an injectedTimeProvider, never an ambient clock (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:113).Why it's built this way: the delivery semantics are the interesting part, and the XML doc states them (
EventFeedbackSubmitted.cs:9-14). Event feedback is an upsert writing one row per form question (BR-107), so one submitted form raises this event once per newly created answer, and only on the create path: the update branch of the same handler raises nothing (AddEventQuestionAnswerHandler.cs:82-95for the update path versus:96-117for the create path). Because at-least-once outbox delivery and a multi-question form both mean the consumer can see the message more than once, the consumer is idempotent on its own side: it collapses everything onto one subject key and lets the awarder's uniqueness rule reject the duplicates (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/EventFeedbackSubmittedPointsHandler.cs:40-47). That is the standard posture for ADR-003 and ADR-021: the producer guarantees the fact was recorded atomically with the data, the consumer guarantees the effect happens once.Where it's used: raised on the aggregate pre-save by
AddEventQuestionAnswerHandler(:112), so the outbox captures it in the sameSaveChangesAsync; consumed byEventFeedbackSubmittedPointsHandler, which maps it onto an event subject key viaPointsSubjectKeysand callsIPointsAwarder(EventFeedbackSubmittedPointsHandler.cs:40-47); registered as a broker consumer in the Engagement service host (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:288).
QuestionChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Questions.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/DomainEvents/QuestionChanged.cs:12· Level 3 · record (sealed)
- What it is: the aggregate-root lifecycle event for a
Question, the reusable custom-form question definition: raised when one is created, updated, or deleted. - Depends on:
EntityChangedEvent<TIdentifierType>,DomainEntityState; aliasQuestionIdentifierType. - Concept: the aggregate-root lifecycle event introduced by
EventChanged.[Rubric §6, CQRS & Event-Driven]. Structurally identical, withQuestionTextas its descriptor. - Walkthrough:
sealed record class QuestionChanged(DomainEntityState State, QuestionIdentifierType QuestionId, string QuestionText)forwarding(State, QuestionId)toEntityChangedEvent<QuestionIdentifierType>(QuestionChanged.cs:12-16). - Where it's used: raised from
Question'sCreate(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:70, raises at:94),Update(:108, raises at:128), andDelete(:135, raises at:140); dispatched in-process. - Caveats / not-in-source: no handler subscribes today.
SessionChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/DomainEvents/SessionChanged.cs:13· Level 3 · record (sealed)
- What it is: the aggregate-root lifecycle event for a
Session: raised on create, update, or delete. - Depends on:
EntityChangedEvent<TIdentifierType>,DomainEntityState; aliasesSessionIdentifierType,EventIdentifierType. - Concept: the aggregate-root lifecycle event (
EventChanged).[Rubric §6, CQRS & Event-Driven]. It is the one root event in this bounded context that carries a second identifier, the parentEventId(line 17), in addition toTitle, so a subscriber knows which event's schedule moved (useful for invalidating that event's session list rather than the whole cache). CompareActivityChanged, whose entity also has anEventIdbut whose event does not publish it: the two are inconsistent, andSessionChangedis the shape worth copying. - Walkthrough:
sealed record class SessionChanged(DomainEntityState State, SessionIdentifierType SessionId, string Title, EventIdentifierType EventId)chaining(State, SessionId)to the base (SessionChanged.cs:13-18). - Where it's used: raised by
Session'sCreate(declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:165, raises at:212),Update(:235, raises at:273), andDelete(:283, raises at:294); captured by the outbox and dispatched in-process. - Caveats / not-in-source: no handler subscribes today.
SessionFeedbackSubmitted
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions.IntegrationEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/IntegrationEvents/SessionFeedbackSubmitted.cs:21· Level 3 · record (sealed)
- What it is: the session-level counterpart of
EventFeedbackSubmitted: the integration event Conference raises when an attendee submits feedback on a session, which Engagement turns into a points award. - Depends on:
BaseIntegrationEventandEventNameAttribute([EventName("Conference.SessionFeedbackSubmitted.v1")], line 20); aliasesUserIdentifierType,SessionIdentifierType,EventIdentifierType; BCLDateTime. - Concept: the integration event introduced by
EventFeedbackSubmitted.[Rubric §7, Microservices Readiness]and[Rubric §9, API & Contract Design]. Same delivery contract (BR-107 upsert, create path only, idempotent consumer,SessionFeedbackSubmitted.cs:9-14); the only payload difference is that it carries both theSessionIdand the owningEventId(lines 23-24), so the consumer can scope the award without a call back into Conference to resolve the session's parent. That extra id is the whole point of a self-contained contract: a cross-service consumer must not need a synchronous lookup to interpret the message. - Walkthrough: four positional members
(
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/IntegrationEvents/SessionFeedbackSubmitted.cs:21-26):UserId,SessionId,EventId, andSubmittedOnUtc. - Where it's used: raised on the aggregate pre-save from two producers, both taking the timestamp from an
injected
TimeProvider.AddSessionQuestionAnswerHandlerraises it on its create path (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:113, insideCreateNewAnswerAsyncat:99-117), andBatchAddSessionQuestionAnswersHandlerraises one per newly created answer inside its per-answer loop (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersHandler.cs:156-157). The batch path is the clearest illustration of why the consumer must be idempotent: one submitted form can emit the event many times inside a single transaction. It is consumed bySessionFeedbackSubmittedPointsHandler, which resolves it onto a session subject key and awards throughIPointsAwarder(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:42-49), and is registered as a broker consumer in the Engagement service host (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:287).
SpeakerChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/DomainEvents/SpeakerChanged.cs:16· Level 3 · record (sealed)
- What it is: the aggregate-root lifecycle event for a
Speaker: raised on create, update, or delete. Its distinctive feature is a nullablePreviousLinkedUserIdthat snapshots the speaker-to-user link as it stood before the operation. - Depends on:
EntityChangedEvent<TIdentifierType>,DomainEntityState; aliasesSpeakerIdentifierType,UserIdentifierType. - Concept: the aggregate-root lifecycle event (
EventChanged), plus carrying pre-mutation state on the event.[Rubric §6, CQRS & Event-Driven]and[Rubric §7, Microservices Readiness](a delete in Conference must trigger unlink cleanup on the Identity side, and that cleanup must not depend on reading a field the delete has already cleared). Per the XML doc (SpeakerChanged.cs:12-15),PreviousLinkedUserIdis populated on theDeletedtransition so the handler can perform the BR-70 cross-context cleanup after the entity's own link field has been nulled. This is also the one event in this part with a live subscriber, so it is the concrete sighting of theIDomainEventHandler<in TDomainEvent>extension point. - Walkthrough:
sealed record class SpeakerChanged(DomainEntityState State, SpeakerIdentifierType SpeakerId, string FullName, UserIdentifierType? PreviousLinkedUserId = null)chaining(State, SpeakerId)to the base (SpeakerChanged.cs:16-21). The defaultnullon the fourth parameter is what keeps the non-delete raise sites a three-argument call:Create(raises atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:168),Update(:235),LinkUser(:284), andUnlinkUser(:302), while theDeleteoverride (declared at:251) is the only four-argument call: it capturesLinkedUserIdinto a local before callingbase.Delete(), nulls the field, and passes the captured value (Speaker.cs:253-265). Note thatLinkUserandUnlinkUserboth emitUpdated, not a bespoke link event, so a subscriber cannot tell a link change from a name edit by event type alone. - Why it's built this way: an event is an immutable record of what already happened, so snapshotting the prior link onto the event avoids a lost-update race in which the cleanup handler would read an already-cleared field. It also decouples the delete transaction from the downstream unlink, which crosses a module and (in the deployed topology) a process boundary.
- Where it's used: consumed by
SpeakerDeletedHandler, the onlyIDomainEventHandlerimplementation in the Conference Application layer (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:22). It ignores every transition exceptDeleted(:29-30), then publishesSpeakerUnlinkedFromUserthroughIEventBuswhenPreviousLinkedUserIdhas a value, from a fresh DI scope because the handler is a singleton (:38-45). Identity then clearsUser.LinkedSpeakerId.
SponsorChanged
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sponsors.DomainEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/DomainEvents/SponsorChanged.cs:12· Level 3 · record (sealed)
- What it is: the aggregate-root lifecycle event for a
Sponsor: raised when a sponsor is created, updated, or soft-deleted. Carries the sponsor id and display name. - Depends on:
EntityChangedEvent<TIdentifierType>,DomainEntityState; aliasSponsorIdentifierType. - Concept: the aggregate-root lifecycle event (
EventChanged).[Rubric §6, CQRS & Event-Driven]and[Rubric §15, Best Practices & Code Quality]. Sponsors are among the newest aggregates in this bounded context, and the fact that its event is a five-line record derived from the same base is the payoff of the shared shape: a new aggregate gets the full lifecycle-event story without inventing anything. - Walkthrough: three positional members (
SponsorChanged.cs:12-16):State,SponsorId, andName, with(State, SponsorId)forwarded toEntityChangedEvent<SponsorIdentifierType>on line 16. - Where it's used: raised from
Sponsor'sCreatefactory (declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:105, raises at:133), itsUpdatepath (:153, raises at:183), and itsDeleteoverride, which calls the base soft-delete first and raises the event only when that base call returned success (:190-198, raise at:195); dispatched in-process. - Caveats / not-in-source: no handler subscribes today.
SpeakerLinkedToUser
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers.IntegrationEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerLinkedToUser.cs:22· Level 3 · record (sealed)
- What it is: the cross-module integration event Conference raises when it binds a
Speakerto an IdentityUser, either through the manualLinkUserToSpeakercommand or through automatic email-match linking triggered byUserRegistered. Identity subscribes and setsUser.LinkedSpeakerId, so the next token refresh carries thespeaker_idclaim (BR-209, per the XML doc atSpeakerLinkedToUser.cs:6-18). - Depends on:
BaseIntegrationEvent(the base it derives from,SpeakerLinkedToUser.cs:25); theEventNameattribute fromMMCA.Common.Domain.Attributes(SpeakerLinkedToUser.cs:1,:21); the identifier aliasesUserIdentifierTypeandSpeakerIdentifierType. - Concept introduced, the integration event (as distinct from the domain event).
[Rubric §7, Microservices Readiness]assesses whether cross-module coupling runs through a published contract a peer can consume without a code reference back into the producer, and[Rubric §9, API & Contract Design]treats the asynchronous message as a public contract in its own right. Three things separate this record from the domain events in this chapter. First, it derives fromBaseIntegrationEventrather thanBaseDomainEvent: a domain event stays inside the producing module, an integration event is meant to cross a module (and eventually a service) boundary over the broker via the outbox. Second, it lives in the.Sharedproject, not.Domain, precisely so the subscribing Identity module can reference the contract without pulling in Conference's domain model. Third, it carries[EventName("Conference.SpeakerLinkedToUser.v1")](SpeakerLinkedToUser.cs:21): the wire name is pinned as data, so the CLR type can be renamed or moved without changing what a subscriber matches on, and the.v1suffix leaves room for a second shape alongside the first. - Walkthrough: the whole type is a positional record,
sealed record class SpeakerLinkedToUser( UserIdentifierType UserId, SpeakerIdentifierType SpeakerId) : BaseIntegrationEvent(SpeakerLinkedToUser.cs:22-25), preceded by theEventNameattribute (:21). Two ids and nothing else: the receiver needs no more than that to setLinkedSpeakerId. The XML doc records that it replaced a former direct in-process call,IUserSpeakerLinkService.LinkSpeakerAsync(SpeakerLinkedToUser.cs:12-17). - Why it's built this way: modeling the link as a published fact rather than a synchronous call is the outbox and eventual-consistency story of ADR-003; it lets Identity and Conference run as separate services with no shared database and no cross-database FK (ADR-006).
- Where it's used: raised on two different paths, and the difference is worth reading. The manual path
in
LinkUserToSpeakerHandlercallsentity.AddDomainEvent(new SpeakerLinkedToUser(...))(LinkUserToSpeakerHandler.cs:63), so the message is serialized into the outbox inside the same save as the aggregate change. The auto-link path inUserRegisteredHandlerinstead publishes throughIEventBus(UserRegisteredHandler.cs:81on the already-linked branch,:101after the link is saved). It is consumed on the Identity side bySpeakerLinkedToUserHandler.
SpeakerUnlinkedFromUser
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers.IntegrationEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerUnlinkedFromUser.cs:19· Level 3 · record (sealed)
- What it is: the inverse of
SpeakerLinkedToUser. Conference raises it when a speaker is unlinked from a user, either via theUnlinkUserFromSpeakercommand or as cascade cleanup when a speaker is soft-deleted; Identity subscribes and clearsUser.LinkedSpeakerId(SpeakerUnlinkedFromUser.cs:6-15). - Depends on:
BaseIntegrationEvent(SpeakerUnlinkedFromUser.cs:22); theEventNameattribute (:1,:18); the aliasesUserIdentifierType,SpeakerIdentifierType. - Concept: the integration event taught at
SpeakerLinkedToUser.[Rubric §7, Microservices Readiness]. Same contract shape, same.Sharedplacement, same pinned wire name ([EventName("Conference.SpeakerUnlinkedFromUser.v1")],SpeakerUnlinkedFromUser.cs:18). The one nuance worth reading off the parameter docs: the id actually being cleared isUserId, andSpeakerIdrides along for audit and log correlation (SpeakerUnlinkedFromUser.cs:16-17). - Walkthrough:
sealed record class SpeakerUnlinkedFromUser(UserIdentifierType UserId, SpeakerIdentifierType SpeakerId) : BaseIntegrationEvent(SpeakerUnlinkedFromUser.cs:19-22). Like its sibling it replaced a direct call, hereIUserSpeakerLinkService.ClearLinkedSpeakerAsync(SpeakerUnlinkedFromUser.cs:10-14). - Why it's built this way: it closes the loop on the eventually-consistent link, and it is the
downstream half of a
SpeakerChangeddelete. That is exactly whySpeaker.Deletesnapshots the previous link id into a local before the soft-delete runs (Speaker.cs:251-254) and carries it on the domain event (Speaker.cs:263): the handler that publishes this integration event would otherwise have nothing left to read. - Where it's used: added to the aggregate by
UnlinkUserFromSpeakerHandler(UnlinkUserFromSpeakerHandler.cs:49, before the save, so the outbox row is written in the same transaction) and published by the speaker-delete cleanup path inSpeakerDeletedHandler(SpeakerDeletedHandler.cs:43, BR-70). Consumed on the Identity side bySpeakerUnlinkedFromUserHandler.
ActivityInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Activities·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:13· Level 6 · class (static)
- What it is: the domain rules for the
Activityaggregate: a required name, three optional venue fields that are length-checked only, and a start-before-end time range check. The length constants declared here are read by domain validation, by the EF configuration, and by the application-layer rule types, so a column width and a domain rule cannot silently diverge (ActivityInvariants.cs:7-12). - Depends on:
CommonInvariants(ActivityInvariants.cs:2),Result(:3), andActivityDTO(:1), which is where the numbers actually live; BCLDateTime. - Concept: the static-invariants-class pattern introduced for the framework at
CommonInvariantsand shown for a Conference aggregate atSessionInvariants.[Rubric §4, Domain-Driven Design]: the rules live in the domain rather than in a handler or a validator. Two things this class teaches that its siblings do not.- The length constant is a forwarder, not a literal. Every
MaxLengthhere ispublic const int X = ActivityDTO.X(ActivityInvariants.cs:16-28). The number is declared once on the Shared DTO, which the Blazor pages bind their input caps to, so the same cap is enforced at the input, at the domain rule, and at the column.[Rubric §8, Data Architecture]and[Rubric §24, Forms/Validation/UX Safety]: the UI stops the user before the domain has to, and neither can drift from the schema, because there is only one number. - The optional field is a first-class domain concept. Three of the five rule methods take a
string?and delegate toCommonInvariants.EnsureOptionalStringMaxLength, which passes a null or empty value. The doc onEnsureVenueNameIsValidstates the reason plainly (:41-44): an empty venue name means the activity happens at the main conference venue, so absence is a meaningful value, not missing data.
- The length constant is a forwarder, not a literal. Every
- Walkthrough
- Length constants (
ActivityInvariants.cs:16-28), allpublic const intforwarding toActivityDTO:NameMaxLength(200),DescriptionMaxLength(2000),VenueNameMaxLength(200),VenueAddressMaxLength(500, chosen to match the event venue address per the doc at:24), andVenueUrlMaxLength(2000). The values themselves are declared atActivityDTO.cs:18,:21,:24,:27, and:30. EnsureNameIsValid(:36-39): the standardResult.CombineofCommonInvariants.EnsureStringIsNotEmptyplusCommonInvariants.EnsureStringMaxLength, tagged with the stable codesActivity.Name.EmptyandActivity.Name.TooLong.EnsureVenueNameIsValid(:48-49),EnsureVenueAddressIsValid(:58-59), andEnsureVenueUrlIsValid(:68-69): each is a single expression delegating toCommonInvariants.EnsureOptionalStringMaxLengthwith its own error code (Activity.VenueName.TooLong,Activity.VenueAddress.TooLong,Activity.VenueUrl.TooLong). Note what is deliberately absent for the URL: no scheme parse, no reachability check. The doc (:61-64) records that the value is stored as an opaque string with no fetch or upload pipeline behind it, matching the sponsor website-URL precedent, so only the storage constraint is enforced here.EnsureTimeRangeIsValid(:79-86): delegates to the genericCommonInvariants.EnsureEndIsNotBeforeStart, failing withActivity.TimeRange.Invalid. The doc (:71-74) explains why the comparison is a plain one: both values are event-local wall times, and the IANA zone lives on the owningEvent, never repeated per row. A zero-length activity is allowed; only an inverted range is rejected.
- Length constants (
- Why it's built this way: pushing the "absent is legal" decision into the invariant, rather than
into every caller, means a handler cannot accidentally require a venue name and the EF column cannot
accidentally be narrower than the rule. Delegating the mechanics (optional max length, end-not-before-
start) to
CommonInvariantsleaves this class holding only what is genuinely Conference vocabulary: which field, which error code, which message. Comparing naive wall times instead of instants keeps the domain free of time-zone conversion, which belongs where the zone is known. - Where it's used:
Activity.CreateandActivity.Update(Activity.cs:111-116and:155-160); the length constants feedActivityConfiguration(ActivityConfiguration.cs:20,:24,:36,:40,:44) and the application-layer rule typesActivityNameRules<T>and its siblings (ActivityValidationRules.cs:17,:29,:41,:53,:71).
Category
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:16· Level 6 · class (sealed, aggregate root)
What it is: the aggregate root for a conference category, for example "Level", "Track", or "Session format" (
Category.cs:10-14). Each category owns a collection ofCategoryItemchildren representing the selectable options inside it.Depends on:
AuditableAggregateRootEntity<TIdentifierType>(the base,Category.cs:16),CategoryInvariants,CategoryItem,Result,DomainEntityState,IdValueGeneratedAttribute,NavigationAttribute, and the domain eventsCategoryChangedandCategoryItemChanged. Identifier aliasesConferenceCategoryIdentifierTypeandCategoryItemIdentifierType.Concept introduced, the aggregate root as the consistency boundary.
[Rubric §4, Domain-Driven Design]assesses whether invariants are enforced inside a boundary and whether children are mutated only through their root. An aggregate root is the only member of its cluster a repository hands out; callers never hold a bareCategoryItem. Two consequences are visible directly in this file:- All child mutation routes through the parent.
AddCategoryItem,UpdateCategoryItem, andRemoveCategoryItem(Category.cs:125,:156,:186) live onCategory, never onCategoryItem, and each raises aCategoryItemChangedfrom the root (for exampleCategory.cs:144) so observers learn the aggregate changed. - The private list enforces encapsulation.
_categoryItemsis aprivate readonly List<CategoryItem>(Category.cs:27); the public surface is the read-only projectionCategoryItems => _categoryItems.AsReadOnly()(Category.cs:31). EF still materializes the backing field, which is why the private parameterless constructor exists (Category.cs:34).
[Rubric §8, Data Architecture]also applies: cascade soft-delete is orchestrated by the aggregate, not by a handler.Delete()(Category.cs:102-114) cascade-soft-deletes every still-active child per BR-71 before raisingCategoryChanged(Deleted).- All child mutation routes through the parent.
Walkthrough
- Marker
[IdValueGenerated](Category.cs:15): category PKs are database-generated, and Sessionize imports still supply explicit ids viaIDENTITY_INSERT(Category.cs:13). - Fields (
Category.cs:19-31):Title,Sort, the optionalType(for example "session" or "speaker"), the private list, and theCategoryItemsprojection tagged[Navigation(IsCollection = true)]so the populator knows this is a child collection (ADR-002). - Constructors (
Category.cs:34-44): the private EF constructor setsTitle = string.Emptyto satisfy the non-nullable field before EF assigns columns; the private field constructor is what the factory calls. Create(Category.cs:54-75): validate throughCategoryInvariants.EnsureTitleIsValid, then resolve whether the id is database-generated viatypeof(Category).IsIdValueGenerated(Category.cs:65), then construct. The id expression is worth reading closely:Id = id ?? (isIdValueGenerated ? default : throw new ArgumentNullException(nameof(id)))(Category.cs:69), so a supplied id always wins, an omitted id is legal only because this type is id-value-generated, and any other combination fails loudly rather than silently writing a zero. FinallyAddDomainEvent(new CategoryChanged(DomainEntityState.Added, ...))(Category.cs:72). This is the canonical validate, then construct, then emit shape used by every aggregate in this chapter.Update(Category.cs:84-98): re-validates the title, writes the three scalars, raisesCategoryChanged(Updated).Delete(Category.cs:102-114): oneResult.CombineofDeleteChildren<CategoryItem, CategoryItemIdentifierType>(_categoryItems)andbase.Delete()(:106-108).DeleteChildrenis the framework helper onAuditableAggregateRootEntity<TIdentifierType>that replaced the loop each aggregate used to hand-roll; it skips already-deleted children so re-deleting a parent is idempotent. The comment at:104-105records the ordering rationale: children first, root last, so a failing child aggregates into the combined result instead of leaving a half-applied delete.AddCategoryItem(Category.cs:125-147): uniqueness check first (BR-138) viaCategoryInvariants.EnsureCategoryItemNameIsUnique, then delegate construction toCategoryItem.Create, then add to the private list, then emit. Callers nevernew CategoryItem(...).UpdateCategoryItem(Category.cs:156-179): resolves the child through the privateGetCategoryItemOrNotFoundhelper (Category.cs:205, which wraps the baseGetChildOrNotFound<T, TId>so a missing child becomes anErrorrather than a null), re-checks uniqueness while excluding the item being renamed (:167-168), then delegates to the child's ownUpdate.RemoveCategoryItem(Category.cs:186-197): a single call to the baseRemoveChildOrNotFound<CategoryItem, CategoryItemIdentifierType>(:188), which resolves and soft-deletes in one step, then emitsCategoryItemChanged(Deleted).SetCategoryItems(Category.cs:201): aninternalhook used only by the navigation populator after a cross-source load. It calls the baseSetItems(_categoryItems, ...)and raises no domain events, because it is hydration, not a domain mutation.
- Marker
Why it's built this way: a single class owning uniqueness, cascade delete, and event emission keeps the consistency rules in one place instead of scattered across handlers, and pushing the mechanical parts (
DeleteChildren,RemoveChildOrNotFound,GetChildOrNotFound) down into the framework base leaves the aggregate holding only its own vocabulary. ADR-002 explains whySetCategoryItemsexists at all: when category and items share a database EFInclude()loads them together, but when they could be split the populator queries separately and calls the hook, so the aggregate stays agnostic to the load path.Where it's used: loaded through
IReadRepository<TEntity, TIdentifierType>, mutated by the Conference category command handlers such asAddCategoryItemHandler, persisted throughConferenceCategoryConfiguration, and projected toConferenceCategoryDTOfor the category UI.
CategoryInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:15· Level 6 · class (static)
- What it is: the invariant rules for
Categoryand itsCategoryItemchildren: title validation, item-name validation, and case-insensitive uniqueness checking (BR-138). - Depends on:
CommonInvariants(the reusable lower layer it delegates to,CategoryInvariants.cs:3),ResultandError(:4),CategoryItem(it takes the child collection as a parameter), andConferenceCategoryDTOplusCategoryItemDTO(:2), where the numbers live; BCLCultureInfo(:1). - Concept: the module invariants class (see
SessionInvariantsandEventInvariants). The distinctive method here, which the simpler invariant classes lack, is the collection-aware uniqueness guard.[Rubric §4, Domain-Driven Design]: the ubiquitous-language rule "an item name is unique within its category" is expressed directly in the domain rather than deferred to a database index or a UI check. - Walkthrough
- Length constants (
CategoryInvariants.cs:18,:21,:24):TitleMaxLength(255),TypeMaxLength(100), andCategoryItemNameMaxLength(500), all forwarding to the Shared DTO constants atConferenceCategoryDTO.cs:17,:20, andCategoryItemDTO.cs:16. Note these arepublic static readonly inthere rather than thepublic const intused by the other invariant classes in this chapter. EnsureTitleIsValid(:26-29) andEnsureCategoryItemNameIsValid(:31-34): each aResult.CombineofCommonInvariants.EnsureStringIsNotEmptyplusCommonInvariants.EnsureStringMaxLength, with the message built throughstring.Create(CultureInfo.InvariantCulture, ...)so the text does not vary by ambient culture. That call is needed here and not in the sibling classes precisely because the length is astatic readonly intrather than a compile-time constant, so the interpolation is evaluated at run time.EnsureCategoryItemNameIsUnique(:44-65): takes the existing item collection plus an optionalexcludeItemId(so renaming an item to its own name during an update does not self-conflict). It skipsIsDeleteditems and compares withStringComparison.OrdinalIgnoreCase(:53-56), returningError.Conflict("CategoryItem.Name.Duplicate")on a duplicate (:58-64). The inline comment at:50-52records why the exclusion is modeled as a nullable rather than defaulted: defaulting todefault(id)would silently exclude every unsaved sibling, since a database-generatedCategoryItemid is 0 until the save.
- Length constants (
- Why it's built this way: co-locating the rules per aggregate keeps the entity itself readable, and
the
Result-returning style composes withResult.Combine. The uniqueness method takes the collection as a parameter so it stays a pure function with no repository and no EF dependency, which is what lets the aggregate call it in memory. - Where it's used: called from
Category'sCreate,Update,AddCategoryItem, andUpdateCategoryItem, and fromCategoryItem'sCreateandUpdate; the length constants are read by the Categories EF configurations (ConferenceCategoryConfigurationat:27and:34,CategoryItemConfigurationat:19). - Caveats / not-in-source:
TypeMaxLengthhas no rule method. Nothing in this class validatesCategory.Type, and neitherCategory.CreatenorCategory.Updatechecks it (Category.cs:60-61,:86-87); the constant's only consumer is the EF column atConferenceCategoryConfiguration.cs:34, so an over-longTypeis caught at the database, not by the domain.
CategoryItem
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:14· Level 6 · class (sealed, child entity)
- What it is: the child entity of
Category: one selectable option inside a category, for example "Beginner" within "Level" or "C#" within "Language" (CategoryItem.cs:8-12). It carriesName,Sort, the back-navigationCategory?, and the FKCategoryId. - Depends on:
AuditableBaseEntity<TIdentifierType>(CategoryItem.cs:14),Category,CategoryInvariants,Result,IdValueGeneratedAttribute,NavigationAttribute. - Concept introduced, the child entity as distinct from the aggregate root.
[Rubric §4, Domain-Driven Design]covers the entity hierarchy within an aggregate. A child entity has its own identity (it extendsAuditableBaseEntity<TIdentifierType>, so it gets soft-delete and audit fields) but it is owned by a root, is never fetched directly from a repository, and, decisively, raises no domain events: itsCreate(CategoryItem.cs:47-65) mirrors the root's validate-then-construct shape but ends without anAddDomainEventcall, because event emission is the root's job. - Walkthrough
[IdValueGenerated](CategoryItem.cs:13): item PKs are database-generated, with the same SessionizeIDENTITY_INSERTexception as the parent.[Navigation] public Category? Category { get; private set; }(CategoryItem.cs:23-24): the back-navigation. The setter is private, and the one legitimate writer goes through the explicitSetCategory(Category?)method atCategoryItem.cs:89, which the navigation populator calls after a cross-source load.[Rubric §1, SOLID]: a named method is a narrower and more searchable extension point than a public setter.CategoryId(CategoryItem.cs:27): the FK, get-only, never externally assigned; EF populates it by relationship fixup off the parent'sCategoryItemsnavigation.Create(CategoryItem.cs:47-65): validates the name throughCategoryInvariants, resolvestypeof(CategoryItem).IsIdValueGenerated(:57), and constructs with the sameid ?? (isIdValueGenerated ? default : throw ...)expression as the root (:61).Update(CategoryItem.cs:73-85): re-validates the name, then writesNameandSort. Again no event; the root'sUpdateCategoryItemraisesCategoryItemChangedaround it.
- Why it's built this way: keeping the child lean, holding only its own field constraints, means a
caller cannot bypass the parent's uniqueness and cascade rules by reaching in and calling
categoryItem.Update(...)directly. The parent method is the only path that also runs BR-138. - Where it's used: loaded through
Category(EFIncludeor the navigation populator); referenced bySpeakerCategoryItemandSessionCategoryItemas the target of those many-to-many bridges; projected toCategoryItemDTO.
EventInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:14· Level 6 · class (static)
- What it is: the static invariants toolbox for the
Eventaggregate and its children (Room,EventQuestionAnswer). It holds the field-length constants and theEnsure...rule methods that the domain factories, the EF configuration, and the application-layer rules all reuse, so a business rule is stated once (EventInvariants.cs:8-13). - Depends on:
CommonInvariants(:2),ResultandError(:3), andEventDTOplusRoomDTO(:1), which declare the numbers; BCLDateOnly. AliasRoomIdentifierType. - Concept: the module invariants class, the same idiom taught for the framework at
CommonInvariantsand for a Conference aggregate atSessionInvariants, here in its widest form: fifteen length constants, a reserved id range, and six rule methods covering a root plus two children.[Rubric §4, Domain-Driven Design](invariants live in the domain, expressed as reusable named rules rather than inlineifblocks) and[Rubric §8, Data Architecture](theMaxLengthconstants are the single source of truth shared by the EF column configuration and by validation, keeping schema and rule in sync). EachEnsure...returns aResultrather than throwing, and callers combine several throughResult.Combine. Read this class next toActivityInvariantsto see the same forwarding discipline: fourteen of the fifteen constants are= EventDTO.Xor= RoomDTO.X, so the number is declared once on the Shared DTO the Blazor input caps also bind to. - Walkthrough, in teaching order:
- Length constants (
EventInvariants.cs:17-59), allpublic const int:NameMaxLength(500),DescriptionMaxLength(4000),TimeZoneMaxLength(100),SessionizeCodeMaxLength(100),VenueAddressMaxLength(500),VenueMapUrlMaxLength(2000),WiFiInfoMaxLength(500),OrganizerContactEmailMaxLength(255,:37),SponsorshipPacketUrlMaxLength(2000,:40),TicketingUrlMaxLength(2000,:43), and the four room limits (RoomNameMaxLength255,RoomFloorMaxLength100,RoomLocationMaxLength255,RoomAccessibilityInfoMaxLength500). The values are declared atEventDTO.cs:19-46andRoomDTO.cs:16-25. The lone exception isAnswerValueMaxLength(4000,:58), a literal here because no DTO owns it: the answer value has no organizer-facing input cap to bind to. - Reserved id range (
EventInvariants.cs:61-69):RoomManualIdRangeStart(999_999_000) andRoomManualIdRangeEnd(999_999_999), bothstatic readonly RoomIdentifierType. Room ids are app-assigned, the int PK is the Sessionize id, so organizer-created rooms draw from this reserved high range and never collide with a real Sessionize id. The comment (:61-63) notes it mirrorsSessionInvariants.ManualIdRangeStart. EnsureNameIsValid(:70-73): aResult.Combineof a not-empty and a max-length check delegated toCommonInvariants.EnsureTimeZoneIsValid(:81-102): an explicitIsNullOrWhiteSpaceguard first (Event.TimeZone.Empty,:83-90), then max length (:92-94), then a delegation toCommonInvariants.EnsureTimeZoneIsValid(:96-101) that maps an unrecognized identifier toEvent.TimeZone.Invalid(BR-87). The framework helper owns the actual BCL lookup; the domain carries no zone table of its own and names only the error code and message.EnsureDateRangeIsValid(:111-118): delegates toCommonInvariants.EnsureEndIsNotBeforeStartwith the codeEvent.DateRange.Invalid, so a single-day event (equal dates) is legal.EnsureRoomCapacityIsValid(:126-132): delegates toCommonInvariants.EnsureNullableIntIsPositivewithRoom.Capacity.Invalid, so anullcapacity passes and a supplied non-positive one fails (BR-93).EnsureRoomNameIsValid(:134-137) andEnsureAnswerValueIsValid(:139-142): not-empty plus max-length pairs for the two children.EnsureEventIsPublished(:150-156): guards actions that require a published event (BR-108), delegating toCommonInvariants.EnsureFlagIsTrueand failing withEvent.NotPublished.
- Length constants (
- Why it's built this way: keeping the length limits as constants read by the EF configuration and by
the domain prevents the classic drift where a validator accepts a value the column then truncates.
Returning
Resultinstead of throwing keeps validation composable at the factory, where several checks are combined into one error list. Every rule that has a reusable mechanism behind it (time-zone lookup, range comparison, nullable positive, flag) is a thin delegation, so the Conference-specific part of each rule is exactly the error code, the message, and the target field name, which is the vocabulary the framework must not invent. - Where it's used: the
Event,Room, andEventQuestionAnswerfactories and updaters call these; the length constants are read byEventConfigurationandRoomConfigurationand by the application-layer event and room validation rules.EnsureEventIsPublishedis called fromAddEventQuestionAnswerHandler(AddEventQuestionAnswerHandler.cs:41) andSessionQuestionAnswerRules(SessionQuestionAnswerRules.cs:46). The reserved room-id range is consumed in two places:AddRoomHandlerallocates the next free id from it and refuses once it is exhausted (AddRoomHandler.cs:132-140), andRoomSyncStrategyskips any Sessionize room whose id falls inside it, recording a warning rather than importing a colliding row (RoomSyncStrategy.cs:95-97).
QuestionInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Questions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:13· Level 6 · class (static)
- What it is: the domain rules for the
Questionaggregate: text length, target entity ("Session", "Event", "Speaker"), input type ("Rating", "Text", "Email"), source ("Sessionize", "User"), and, the richest part, type-specific answer validation (BR-124). - Depends on:
CommonInvariants(:2),ResultandError(:3),QuestionDTO(:1); BCLint.TryParse,NumberStyles,CultureInfo, andSystem.Net.Mail.MailAddress. - Concept: the same invariants-class pattern as
EventInvariants, but notably richer.[Rubric §4, Domain-Driven Design]: the closed value sets and the answer rules are expressed as domain logic, not as API or UI validation. The permitted values are held as data rather than as longswitchstatements:ValidQuestionEntities,ValidQuestionTypes, andValidQuestionSourcesareprivate static readonly string[](QuestionInvariants.cs:31,:34,:37) checked withStringComparer.OrdinalIgnoreCase. - Walkthrough
- Length constants (
QuestionInvariants.cs:16-28):QuestionTextMaxLength(1000) and the three 20-char discriminator limits (QuestionEntityMaxLength,QuestionTypeMaxLength,QuestionSourceMaxLength), all forwarding toQuestionDTO(QuestionDTO.cs:17-26), plusTextAnswerMaxLength(2000,:28), the one literal, because the answer cap is a BR-124 rule rather than a bound input field. - The user-created id range
ManualIdRangeStart/ManualIdRangeEnd(:40,:43, 999_999_000 to 999_999_999), distinguishing Sessionize ids from user-created ones, the same deviceSessionInvariantsandEventInvariantsuse. EnsureQuestionTextIsValid(:51-63): an explicitIsNullOrWhiteSpaceguard first, then max length viaCommonInvariants.EnsureStringMaxLength.EnsureQuestionEntityIsValid(:71-78),EnsureQuestionTypeIsValid(:86-93), andEnsureQuestionSourceIsValid(:101-108): membership tests against the closed arrays, each returning a specificError.Invariantcode.EnsureAnswerValueMatchesQuestionType(:118-129): aswitchexpression onquestionTypedispatching to three private validators, because what counts as a valid answer depends on the question's type:ValidateRatingAnswer(:131-143):int.TryParsewithNumberStyles.IntegerandCultureInfo.InvariantCulture, requiring 1 to 5, otherwiseError.Validation. The invariant culture is deliberate: a rating must parse identically wherever the request originates.ValidateTextAnswer(:145-157): length must not exceedTextAnswerMaxLength(2000).ValidateEmailAnswer(:159-174): constructs aSystem.Net.Mail.MailAddressand treats aFormatExceptionas invalid, letting the BCL be the format authority.- An unrecognized type falls through to
Error.Invariant("Question.QuestionType.Unknown")(:124-128). Note the dispatch is an ordinalswitchon the literal strings, so it is case-sensitive here even thoughEnsureQuestionTypeIsValidaccepts any casing.
- Length constants (
- Why it's built this way: encoding answer-shape rules in the domain means the model rejects a malformed rating or email before it can reach a handler or the database, and expressing the allowed sets as arrays keeps adding a new question type a one-line data change rather than a code restructure.
- Where it's used: called from
Question'sCreateandUpdate;EnsureAnswerValueMatchesQuestionTypeis applied by the answer-recording paths in the Application tier,AddEventQuestionAnswerHandler(AddEventQuestionAnswerHandler.cs:78) andSessionQuestionAnswerRules(SessionQuestionAnswerRules.cs:69); the length constants feedQuestionConfiguration(QuestionConfiguration.cs:19,:23,:27,:37). - Caveats / not-in-source: the XML doc on
EnsureQuestionEntityIsValid(:66) still says the valid values are "Session" or "Event", while the array (:31) and the failure message (:75) both include "Speaker". The array is the operative rule; that one doc line is stale.
Event
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:24· Level 7 · class (sealed, aggregate root)
- What it is: the aggregate root for a conference event. It owns three child collections
(
Rooms,EventSpeakerassociations, andEventQuestionAnswers) and enforces every rule about them through its own methods. Event ids are database-generated, not sourced from Sessionize (Event.cs:13-16). - Depends on:
AuditableAggregateRootEntity<TIdentifierType>andIAuditedEntity(both onEvent.cs:24),EventInvariants,ResultandError,DomainEntityState,IdValueGeneratedAttribute,NavigationAttribute,Email(fromMMCA.Common.Shared.ValueObjects.Contact,Event.cs:9),QuestionModerationDefault(fromMMCA.ADC.Conference.Shared.Events.Live,Event.cs:2), and theEventChanged/RoomChanged/EventSpeakerChanged/EventQuestionAnswerChangeddomain events. AliasesEventIdentifierType,RoomIdentifierType,EventSpeakerIdentifierType,EventQuestionAnswerIdentifierType,SpeakerIdentifierType,QuestionIdentifierType,UserIdentifierType. - Concept: the aggregate root taught at
Category, here in its fullest expression in this chapter, plus two thingsCategorydoes not show.[Rubric §4, Domain-Driven Design]and[Rubric §1, SOLID]: child collections are exposed only as read-only views over private backing lists, all mutation flows through root methods, each mutation validates and then raises a domain event, and the root owns cascade delete.[Rubric §6, CQRS & Event-Driven]: every state change announces itself, which is what gives the outbox a single ordered stream. Two additions worth naming:- Selective auditing.
EventimplementsIAuditedEntity(Event.cs:24). The class doc (Event.cs:17-21) states the reason as a cost-benefit judgment rather than a blanket policy: the event record is the schedule everything else hangs off, several organizers edit it, and a wrong date, venue or live window is felt by every attendee, so one trail row per change is worth it. - Selective navigation.
RoomsandEventSpeakersare marked[Navigation(IsCollection = true)](Event.cs:91,:97) butEventQuestionAnswersdeliberately is not (Event.cs:102-112).[Rubric §12, Performance & Scalability]: the remarks record that the collection grows with attendance rather than with the schedule, that it rode along on public reads that never render it (PublicSessionListpulls events-with-children only to build a room-name dictionary), and that it is per-attendee feedback behind an anonymous endpoint. Handlers that genuinely need it pass an explicitincludes:list instead.
- Selective auditing.
- Walkthrough, in teaching order:
[IdValueGenerated]on the class (Event.cs:23): the factory reads this at run time throughtypeof(Event).IsIdValueGenerated(Event.cs:202).- Scalar state (
Event.cs:26-86):Name,Description?,StartDate/EndDate(DateOnly),TimeZone,SessionizeCode?,VenueAddress?,VenueMapUrl?,WiFiInfo?,OrganizerContactEmail?(:59, typed as the sharedEmailvalue object rather than astring(ADR-068), so the format invariant travels with the value instead of being restated by every caller; when absent the public event page falls back to the host-configured support address),SponsorshipPacketUrl?(:65, whose absence hides the sponsorship call to action entirely),TicketingUrl?(:71, whose absence likewise hides the ticketing call to action on the landing and public event pages),IsPublished,QuestionModerationDefault(:80, the BR-233 initial status a newly submitted live-layer question receives), and the nullableLastSessionizeRefreshOn/LastSessionizeRefreshByrefresh-audit pair. All have private setters. - Child collections (
Event.cs:88-112): three privateList<T>backing fields exposed asIReadOnlyCollection<T>projections. - Constructors (
Event.cs:115-147): a private parameterless EF constructor that seeds the non-nullable strings, plus a private twelve-parameter field constructor used by the factory. Create(Event.cs:170-225): takes the organizer email as a rawstring?and converts it first (:186-193), returningEmail.Create's own failure before any other validation runs, so the email format rule is stated once on the value object and merely surfaced here; then combinesEnsureNameIsValid,EnsureTimeZoneIsValid, andEnsureDateRangeIsValid(:195-198); on success builds the instance withId = isIdValueGenerated ? default : id!.Value(:218) and setsQuestionModerationDefault(:219, defaulted toQuestionModerationDefault.Pendingat the parameter,:181), then raisesEventChanged(Added)(:222).Update(Event.cs:247-295): runs the same string-to-Emailconversion (:262-269), re-validates the same three invariants (:271-274), writes the scalars including the moderation default, the converted email (:288) and the two URLs, then raisesEventChanged(Updated)(:292).PublishandUnpublish(Event.cs:299,:319): flipIsPublished, refusing a no-op transition withEvent.AlreadyPublished/Event.AlreadyUnpublished, and raiseEventChanged(Updated).RecordSessionizeRefresh(Event.cs:343-347): stampsLastSessionizeRefreshOn/Byfrom a caller-supplied UTC instant. The parameter doc (:339-342) is explicit that the value comes from an injectedTimeProviderso the domain never reads an ambient clock.[Rubric §14, Testability]. Note this method returnsvoidand raises no event.Delete(Event.cs:355-370): overrides the base soft-delete as oneResult.Combineof threeDeleteChildren<T, TId>calls (rooms, event-speakers, answers) plusbase.Delete()(:360-364, BR-72), then raisesEventChanged(Deleted)(:367) only when the whole cascade succeeded. The comment at:357-359records why children come first:Result.Combineaggregates every child failure with the root's own, so a failing child leaves the cascade reported as a failure instead of a half-applied delete. Session cascade is deliberately not here: it is handled a layer up (BR-127) because sessions are separate aggregates, which is whatIEventCascadeDeletionDomainServiceexists for.- Room management (
Event.cs:385-526):AddRoom(:385) checks name uniqueness first (:394), delegates toRoom.Create, adds, and raisesRoomChanged(Added)(:406);UpdateRoom(:422) resolves the child, re-checks uniqueness excluding itself (:436), and delegates (:440).RestoreRoom(:465) is the BR-135 reactivation path and the most defensive method on the type. It takes the room instance rather than an id because a soft-deleted row is excluded by the global query filter and so is not reachable through the loaded collection (:455-460), and it then runs its guards in order: the room must belong to this event (:477,Event.Room.WrongEvent), whose comment at:469-473explains the stakes precisely (Room.EventIdhas no setter and is populated purely by EF relationship fixup off thisRoomsnavigation, so adding a foreign room here would silently rewrite itsEventIdon save and move the row out of its real event); the incoming name must clear the same uniqueness bar as an add (:486, with the comment at:483-485noting that otherwise a Sessionize refresh restoring a room whose name an organizer has since reused would fail on the database index and abort the whole refresh); and only thenroom.Updateruns (:492) before the baseRestoreChild<Room, RoomIdentifierType>helper (:496-497), so a rejected name leaves the room untouched and still deleted rather than half-restored.RestoreChildis where the "must actually be soft-deleted" guard lives, which is why the aggregate hands it the error code"Event.Room.NotDeleted"as a parameter (:497): the framework enforces the rule, the module owns the vocabulary. It raisesRoomChanged(Added)(:501) because the room re-enters the visible set.RemoveRoom(:511) delegates toRemoveChildOrNotFound(:513) and raisesRoomChanged(Deleted)(:518). - Event-speaker management (
Event.cs:536-608):AddEventSpeaker(:536) guards duplicates in memory (:543) withEvent.Speaker.Duplicate;RestoreEventSpeaker(:573) is the join-entity counterpart toRestoreRoomand is far shorter, a singleRestoreChildcall with"Event.Speaker.NotDeleted"(:577-578), because the join carries no organizer-entered data, so there is nothing to re-apply and no uniqueness to re-check (:566-570);RemoveEventSpeaker(:592) soft-deletes throughRemoveChildOrNotFound. - Answer management (
Event.cs:619-677):AddEventQuestionAnswer(:619),UpdateEventQuestionAnswer(:643),RemoveEventQuestionAnswer(:666). Unlike the two collections above, the add has no duplicate guard: an event answering the same question twice is not blocked in the domain. - Populator hooks (
Event.cs:525,:607,:681):SetRooms,SetEventSpeakers, andSetEventQuestionAnswersareinternaland call the baseSetItems, raising no events (ADR-002). - Private helpers (
Event.cs:695-722):EnsureRoomNameIsUnique(:695), whose doc comment notes the ordinal-ignore-case comparison is chosen to match the database uniqueness index under the server's default case-insensitive collation, and which uses the same nullable-exclusion shape asCategoryInvariants(:703); plus twoGet...OrNotFoundwrappers over the baseGetChildOrNotFoundso a missing child returns anErrorrather than a null,GetRoomOrNotFound(:714) andGetEventQuestionAnswerOrNotFound(:719). There is no event-speaker equivalent, because no method on this type needs to resolve a join by id and then act on it: add guards in memory, restore takes the instance, and remove goes straight throughRemoveChildOrNotFound.
- Why it's built this way: routing every child change through the root is what makes the invariants
(no duplicate room name, cascade on delete) enforceable at all, and what gives the outbox an ordered
change stream. Passing the clock in rather than reading
DateTime.UtcNowkeeps the domain deterministic. Holding the organizer contact as anEmailrather than astringmeans an invalid address cannot exist on a stored event at all, and the factory andUpdateonly forward the value object's failure instead of each writing their own format check. The restore methods exist because Sessionize is an upstream feed that can withdraw and reinstate a room or a speaker, and reactivating a soft-deleted row preserves its id and history where re-creating it would not (BR-135). The mechanical halves of all of that (cascade, remove, restore, resolve) live on the framework base, so this file reads as a list of Conference rules rather than a list of collection manipulations. - Where it's used: loaded and mutated by the Conference application-layer command handlers (Group 18);
persisted through
EventConfiguration; hydrated byEventNavigationPopulator; projected toEventDTOfor the read endpoints; and referenced by FK fromActivity,Room,EventSpeaker, andEventQuestionAnswer.
EventQuestionAnswer
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:13· Level 7 · class (sealed, child entity)
- What it is: a child entity of
Eventstoring the event's answer to one custom-formQuestion(EventQuestionAnswer.cs:8-11). Database-generated id. - Depends on:
AuditableBaseEntity<TIdentifierType>(EventQuestionAnswer.cs:13),EventInvariants,Result,IdValueGeneratedAttribute,NavigationAttribute. AliasesEventQuestionAnswerIdentifierType,QuestionIdentifierType,EventIdentifierType. - Concept: the child entity taught at
CategoryItem, here under a different root.[Rubric §4, Domain-Driven Design]. It has identity, soft-delete, and audit fields but no domain-event list of its own: neitherCreatenorUpdateAnswercallsAddDomainEvent, becauseEventwraps both and raisesEventQuestionAnswerChangeditself. UnlikeRoomandEventSpeakerit does not implementIReactivatable, which is the decisive difference: a withdrawn answer is not something an upstream feed reinstates, so the type never publishes a reactivation capability and the baseRestoreChildhelper cannot be pointed at it. - Walkthrough:
[IdValueGenerated](:12);QuestionId(the FK to the answered question) andAnswerValue, both with private setters (:15-19); the[Navigation] Event?back-navigation with a private setter and the get-onlyEventIdFK (:21-26); a private EF constructor that seedsAnswerValue = string.Emptyand a private field constructor (:28-37);Create(:46-64), which validates throughEventInvariants.EnsureAnswerValueIsValidand assignsId = isIdValueGenerated ? default : id!.Value(:60);UpdateAnswer(:71-80), which re-validates and then writesAnswerValue; andSetEvent(Event?)(:84), the explicit populator hook that replaces a public navigation setter. - Why it's built this way: keeping the answer a child of the event rather than a standalone aggregate means it shares the event's transaction and cascade delete, and its lifecycle notifications flow through the root's ordered event stream.
- Where it's used: created and mutated only through
Event'sAddEventQuestionAnswer,UpdateEventQuestionAnswer, andRemoveEventQuestionAnswer; mapped byEventQuestionAnswerConfiguration. Because the collection is not marked[Navigation], handlers that need it request it explicitly rather than getting it from the populator. - Caveats / not-in-source: nothing in this file checks that
AnswerValuematches the referenced question's type.EventInvariants.EnsureAnswerValueIsValid(EventInvariants.cs:140) only enforces not-empty plus 4000 characters. The BR-124 shape rule (QuestionInvariants.EnsureAnswerValueMatchesQuestionType,QuestionInvariants.cs:118) is applied one layer up byAddEventQuestionAnswerHandler(AddEventQuestionAnswerHandler.cs:78), where the handler has the question in hand; the reason the check cannot live in a validator is recorded atUpdateEventQuestionAnswerCommandValidator.cs:14.
EventSpeaker
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:14· Level 7 · class (sealed, join entity)
- What it is: the join entity linking an
Eventto aSpeaker, that is, which speakers appear at which event (EventSpeaker.cs:9-12). Database-generated id. - Depends on:
AuditableBaseEntity<TIdentifierType>andIReactivatable(both onEventSpeaker.cs:14),Result,IdValueGeneratedAttribute,NavigationAttribute. AliasesEventSpeakerIdentifierType,SpeakerIdentifierType,EventIdentifierType. - Concept introduced, the explicit join entity.
[Rubric §4, Domain-Driven Design]and[Rubric §8, Data Architecture]. Rather than let EF create an implicit link table, the many-to-many is modeled as a real entity, which is what gives the association its own id, its own soft-delete flag, and its own audit trail. This is the thinnest child in the chapter: it holds only theSpeakerIdFK plus the standard back-navigation andEventId, soCreate(:37-49) does no validation at all beyond assigning the id. There is noUpdate, because a join either exists or it does not. - Walkthrough:
[IdValueGenerated](:13);SpeakerId(:17); the[Navigation] Event?with a private setter and the get-onlyEventId(:19-24); an empty private EF constructor and a one-line private field constructor (:27,:29);Create(:37-49);Reactivate()(:57), a one-line delegation to the baseUndelete(); andSetEvent(Event?)(:61) for the populator. TheReactivatedoc (:51-56) explains its reason for existing: the join row carries the Sessionize-assigned speaker id, so an association that reappears in the feed is reactivated rather than duplicated by a second row (BR-135). ImplementingIReactivatableis what makes the type eligible for the aggregate'sRestoreChildhelper at all: the baseUndelete()is non-public on purpose, so reversing a soft delete is a decision each entity publishes for itself. - Why it's built this way: an explicit join entity is what lets
EventraiseEventSpeakerChangedwhen the association is added, restored, or removed, and it is what makes the soft-delete-then-reactivate cycle possible under a repeatedly re-run import. - Where it's used: created, restored, and removed only through
Event'sAddEventSpeaker,RestoreEventSpeaker, andRemoveEventSpeaker; the duplicate-speaker guard lives in the root (Event.cs:540), not here. Mapped byEventSpeakerConfiguration.
Question
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Questions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:14· Level 7 · class (sealed, aggregate root)
- What it is: a standalone aggregate root for a survey or custom-form question, for example "Dietary
requirements" or "T-shirt size" (
Question.cs:9-13). A question targets an entity type (QuestionEntity), has an input type (QuestionType), a sort order, anIsRequiredflag, and aQuestionSource. Unlike the other roots in this part it owns no children: answers live on the answering entity (EventQuestionAnswer,SpeakerQuestionAnswer,SessionQuestionAnswer). - Depends on:
AuditableAggregateRootEntity<TIdentifierType>(Question.cs:14),QuestionInvariants,Result,DomainEntityState, and theQuestionChangeddomain event. AliasQuestionIdentifierType. - Concept: a "thin" aggregate root, where the consistency boundary is just the record itself. The
detail worth noticing is the absent attribute: the class header carries no
[IdValueGenerated](Question.cs:14), so question ids are explicitly assigned, typically by Sessionize.Createstill runs the sametypeof(Question).IsIdValueGeneratedcheck (:87), which here evaluates tofalse, so theid!.Valuebranch is always taken (:91).[Rubric §8, Data Architecture]: the id-origin decision is expressed once, as an attribute on the type (or its absence), and every factory reads it uniformly. - Walkthrough
- Scalars (
Question.cs:16-32):QuestionText,QuestionEntity,QuestionType,Sort,IsRequired,QuestionSource, all with private setters. The three discriminators are plain strings validated against the closed sets inQuestionInvariantsrather than enums. - Constructors (
Question.cs:35-57): the EF constructor seeds all four non-nullable strings. Create(Question.cs:70-97): four invariant checks (text, entity, type, source) combined throughResult.Combine(:79-83) so the caller gets every problem at once, then construct, then emitQuestionChanged(Added)(:94).Update(Question.cs:108-131): re-validates text, entity, and type, but drops thequestionSourceparameter entirely. Source is immutable after creation, a business rule encoded by absence rather than by a guard clause.Delete(Question.cs:135-143): calls the base soft-delete and, on success, emitsQuestionChanged(Deleted). No cascade loop and noDeleteChildrencall, because it owns nothing.
- Scalars (
- Why it's built this way: validating against closed value lists rather than accepting free-form
strings means the domain rejects an invalid type, entity, or source before persistence. Making
QuestionSourcenon-updatable preserves the provenance distinction between an imported question and a user-created one, which is what the reserved manual id range inQuestionInvariantsalso protects. - Where it's used: referenced by scalar FK (
QuestionId) fromEventQuestionAnswer,SpeakerQuestionAnswer, andSessionQuestionAnswer; mapped byQuestionConfiguration; projected toQuestionDTOand fed into the feedback and custom-form features in the Application and UI tiers. - Caveats / not-in-source:
QuestionEntityaccepts "Speaker" (QuestionInvariants.cs:31) while the property's own XML doc still says "Session" or "Event" (Question.cs:19), as do theCreateparameter docs (:64). The array is the operative rule; those doc comments are stale.
Room
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:13· Level 7 · class (sealed, child entity)
- What it is: a child entity of
Eventrepresenting a physical or virtual room where sessions take place. Unlike its siblings, a room's id is Sessionize-assigned, not database-generated (Room.cs:9-12, and note the absence of[IdValueGenerated]onRoom.cs:13). - Depends on:
AuditableBaseEntity<TIdentifierType>andIReactivatable(both onRoom.cs:13),EventInvariants,Result,NavigationAttribute. AliasesRoomIdentifierType,EventIdentifierType. - Concept: the child entity (
CategoryItem,EventQuestionAnswer) with an externally assigned id.[Rubric §8, Data Architecture]. BecauseRoomis not marked[IdValueGenerated],typeof(Room).IsIdValueGenerated(Room.cs:85) is false andCreatealways assigns the supplied id (:95), which is how a Sessionize room id becomes the PK directly. Organizer-created rooms therefore draw from the reserved high range (EventInvariants.RoomManualIdRangeStart) so app-assigned ids never collide with imported ones. - Walkthrough: scalars
Name,Sort,Capacity?,Floor?,Location?,AccessibilityInfo?(Room.cs:15-31); the[Navigation] Event?with a private setter and the get-onlyEventId(:33-38); the EF constructor and the private field constructor (:41-57);Create(:70-99) validatingEnsureRoomNameIsValidplusEnsureRoomCapacityIsValid(:79-81);Update(:111-133) re-validating the same pair and writing all six scalars;Reactivate()(:141), theIReactivatableimplementation delegating to the baseUndelete(), whose doc (:135-140) explains that a room reappearing in the Sessionize feed has to be reactivated rather than re-created precisely because its id is externally owned (BR-135); andSetEvent(Event?)(:145) for the populator. As a child it raises no events itself. - Why it's built this way: preserving the Sessionize id as the PK keeps imported rooms stable across
refreshes, so a re-import updates in place instead of creating duplicates, and the reserved manual range
lets organizers add rooms without an id clash. Note the room-name uniqueness rule is not here: it
lives in
Event(Event.cs:695), because uniqueness is a statement about the collection, which only the root can see. The same reasoning puts the "does this room belong to this event" check in the root as well (Event.cs:474):EventIdis get-only here (Room.cs:38), so only EF relationship fixup ever sets it. - Where it's used: created, updated, restored, and removed through
Event'sAddRoom,UpdateRoom,RestoreRoom, andRemoveRoom, each of which raisesRoomChanged; mapped byRoomConfiguration; projected toRoomDTO; referenced bySessionscheduling.
Activity
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Activities·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:20· Level 8 · class (sealed, aggregate root)
- What it is: the aggregate root for a social or networking activity attached to a conference event: a
pre-conference party, a morning coffee connect, an after-party, a closing ceremony (
Activity.cs:11-18). It carries a name, an optional description, a start and end time, three optional venue fields, a sort order, and the FK to its owningEvent. Activity ids are database-generated. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>(the base,Activity.cs:20),ActivityInvariants,Event(the navigation target,:58),Result,DomainEntityState,IdValueGeneratedAttribute,NavigationAttribute, and theActivityChangeddomain event. AliasesActivityIdentifierType,EventIdentifierType. - Concept: the aggregate root taught at
CategoryandEvent, in its childless form (compareQuestion). What Activity teaches that the others do not is a modeling decision stated outright in the class doc (Activity.cs:11-18): an activity is deliberately not aSession. It has no room and no speakers, and it frequently happens at an external venue, so the venue is carried on the activity itself instead of being inherited from the event.[Rubric §4, Domain-Driven Design]: rather than overloadSessionwith nullable room/speaker/venue fields and a "kind" discriminator, the ubiquitous language gets a second, smaller aggregate whose invariants are genuinely different.[Rubric §15, Best Practices & Code Quality]: the cost of that choice is a parallel command, query, and UI slice, and the benefit is that neither type carries the other's optionality. - Walkthrough
[IdValueGenerated]on the class (Activity.cs:19): activities are planned, not imported from Sessionize, so the database owns the id.Createreads it throughtypeof(Activity).IsIdValueGenerated(:120).- Scalars (
Activity.cs:22-54):Name,Description?,StartTime/EndTime,VenueName?,VenueAddress?,VenueUrl?,SortOrder, andEventId, all with private setters. Read the two time docs carefully (:28-32,:35): both are plain wall-clockDateTimevalues in the owning event's IANA time zone, exactly asSession.StartsAtdoes, and the zone lives on the event, never repeated per row.SortOrder(:50) exists only to break ties between activities starting at the same time. [Navigation] public Event? Event(Activity.cs:56-58): a single-reference navigation (not a collection) with a private setter, described in its doc as read-only and used for public visibility filtering, so a public read can honor the parent event's published state (ADR-002). The populator writes it throughSetEvent(Event?)(:192).- Constructors (
Activity.cs:61-83): the private parameterless EF constructor seedsName = string.Empty; the private nine-parameter field constructor is what the factory calls. Create(Activity.cs:99-130): a five-wayResult.CombineoverActivityInvariants(:111-116) so a caller sees every problem at once, thenId = isIdValueGenerated ? default : id!.Value(:124), thenAddDomainEvent(new ActivityChanged(DomainEntityState.Added, activity.Id, activity.Name))(:127).Update(Activity.cs:145-176): the same five checks (:155-160), then eight scalar writes, thenActivityChanged(Updated)(:173). Note the parameter list has noeventId: the doc (:132-135) records that the owning event is not updatable, and that moving an activity between events is a create plus a delete.Delete(Activity.cs:180-188): overrides the base soft-delete and, on success, raisesActivityChanged(Deleted). There is no cascade, because the aggregate owns no children.
- Why it's built this way: storing event-local wall times rather than instants means an organizer
edits the time they see printed on the schedule, and the single authoritative zone on
Eventis applied once at render. Keeping venue on the activity is what lets an off-site after-party carry its own address and map link while an on-site coffee connect simply leaves the fields null and the reader falls back to the event venue. - Where it's used: mutated by the Conference activity command handlers and mapped to
ActivityDTObyActivityDTOMapper; hydrated byActivityNavigationPopulator; persisted throughActivityConfiguration; rendered by theActivityList,ActivityDetail, andActivityCreatepages.
IEventCascadeDeletionDomainService
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/IEventCascadeDeletionDomainService.cs:14· Level 9 · interface
- What it is: a pure domain-service abstraction that coordinates the cascade soft-delete of an
Eventtogether with itsSessions (BR-127), itsSponsors, and itsActivitys. All three are separate aggregates fromEvent, soEvent.Delete()alone cannot reach them. - Depends on:
Event(Level 7),Session(Level 8),Sponsor(Level 8),Activity(Level 8),Result(Level 2). Nothing else: no repository, noDbContext, no logger, and the fiveusingdirectives (:1-5) confirm it. - Concept introduced, domain services for cross-aggregate coordination.
[Rubric §4, Domain-Driven Design](assesses whether logic belonging to no single aggregate gets a named home instead of leaking into a handler) and[Rubric §3, Clean Architecture](assesses whether the Domain layer stays free of outward dependencies). When a business operation spans two or more aggregate boundaries it belongs in a domain service. Deleting an event must also soft-delete its sessions (BR-127, BR-55), its sponsors, and its activities, but all four have separate identity and lifecycle, so no one of them can own the rule. The interface takes pre-fetched aggregates, and the doc comment (:12-13) says so: "Operates on pre-fetched aggregates with no infrastructure dependencies." That is what keeps the abstraction in the Domain layer: loading is the caller's job, orchestration is this type's job. Both the interface and its implementation live inMMCA.ADC.Conference.Domain.Services, not in Infrastructure, because neither needs anything the Domain layer cannot reference. - Walkthrough: one member,
Result CascadeDelete(Event @event, IReadOnlyCollection<Session> sessions, IReadOnlyCollection<Sponsor> sponsors, IReadOnlyCollection<Activity> activities)(:28-32). The three child parameters are read-only collections, which states that the service will mutate the entities but never the collections. The contract documented at:17-27is the important part: the first session, sponsor, or activity that fails to delete aborts the cascade, so the event is not deleted when any child aggregate delete fails, and the returnedResultis either that failing child result or the result of the event deletion. - Why it's built this way: an interface here buys two things.
[Rubric §14, Testability]: the concrete service can be unit-tested with plain domain objects, andDeleteEventHandlercan be tested against a stub without constructing a real cascade.[Rubric §1, SOLID]: the handler depends on the abstraction and stays a thin fetch, coordinate, persist slice. The signature is also the honest record of a design cost: each new event-rooted aggregate (sponsors and activities were both added after sessions) widens this contract, which is a visible, compile-checked change rather than a silent gap in the cascade. - Where it's used: injected into
DeleteEventHandler(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:20) and invoked at:64of that file. Registered as a singleton in the Conference Application DI,services.TryAddSingleton<IEventCascadeDeletionDomainService, EventCascadeDeletionDomainService>()(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:58, under the "Domain services" banner comment at:54).
EventCascadeDeletionDomainService
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventCascadeDeletionDomainService.cs:15· Level 10 · class (sealed)
What it is: the one implementation of
IEventCascadeDeletionDomainService. A stateless class that soft-deletes an event'sSessions, then itsSponsors, then itsActivitys, then theEventitself.Depends on:
IEventCascadeDeletionDomainService(Level 9),Event,Session,Sponsor,Activity,Result. Its fiveusingdirectives (:1-5) are the whole dependency list, and none of them is an infrastructure namespace.Concept: see
IEventCascadeDeletionDomainServicefor the domain-service rationale. This class is the smallest possible realization of it: no fields, no constructor, one method. The class doc states the property that makes it safe to register as a singleton, "Pure domain service -- no infrastructure dependencies" (:11), and being stateless it is thread-safe by construction.Walkthrough:
CascadeDelete(Event @event, IReadOnlyCollection<Session> sessions, IReadOnlyCollection<Sponsor> sponsors, IReadOnlyCollection<Activity> activities)(:19-23) runs four phases in a fixed order:- Sessions first (
:28-33):foreachsession, callsession.Delete()(BR-127; each session in turn cascades to its own children per BR-55, per the inline comment at:30). The per-sessionResultis inspected:if (sessionResult.IsFailure) return sessionResult;(:31-32) exits immediately with that child's error. - Then sponsors (
:37-42): the identical shape oversponsor.Delete(), with the same short-circuit (:40-41). The comment at:35-36records the intent: a sponsor that refuses to delete leaves the event untouched. - Then activities (
:47-52): the same shape again overactivity.Delete()(:50-51); the comment at:44-46notes that leaving them behind would orphan rows the public activities page still reads. - Then the event (
:55):return @event.Delete(), which itself cascades to the event's owned children (rooms, event speakers, event question answers) per BR-72, and thatResultbecomes the method's return value.
Each
Delete()also queues its aggregate's domain event (SponsorChanged(Deleted)for a sponsor, and the equivalent for sessions, activities and the event), which the unit of work dispatches afterSaveChangesAsync.- Sessions first (
Why it's built this way:
[Rubric §8, Data Architecture](assesses whether a multi-entity write can leave the store half-changed): the short-circuit plus the caller's save discipline is the whole consistency story. The service aborts in memory, andDeleteEventHandlercallsSaveChangesAsynconly when the returnedResultis a success (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:65-67), so the already-appliedIsDeletedflags on the earlier aggregates are discarded with the scopedDbContextinstead of being persisted. The inline comment at:25-27spells that contract out, which matters: the safety depends on the caller, so a future consumer that saves unconditionally would persist a half-deleted graph.[Rubric §14, Testability]: with no infrastructure to stub, the whole behavior is exercised by passing domain objects and assertingIsDeletedand the queued domain events.Where it's used: resolved through the interface by
DeleteEventHandler, which loads the event with its owned children (DeleteEventHandler.cs:29-33), its active sessions with their children (:38-43), its active sponsors (:47-52) and its active activities (:56-61), allasTracking: true, before callingCascadeDelete(:64). Unit-tested byEventCascadeDeletionDomainServiceTests.Caveats / not-in-source: the ordering (sessions, then sponsors, then activities, then event) is fixed by the method body and is not configurable; nothing in the source explains why sessions precede sponsors and activities, and since all three are short-circuiting the choice only affects which error a caller sees when more than one would fail.
SpeakerInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:13· Level 6 · class (static)
- What it is: the domain rule set for the
Speakeraggregate and itsSpeakerQuestionAnswerchild. Ten field-length constants plus threeEnsureXxxguards that the two entities call before they mutate anything. - Depends on:
CommonInvariants(Level 5),Result(Level 2), andSpeakerDTO(Level 2) for the numbers themselves. No BCL or NuGet dependency beyond string interpolation. - Concept: the invariant-class pattern itself is taught on
CategoryInvariants. What this sibling shows is the three-layer constant chain, and it runs one hop further than most. The class doc (SpeakerInvariants.cs:7-12) states the rule: "The numbers themselves live onSpeakerDTO, the lowest layer the UI can also reach, so markup and domain validation cannot drift apart." So nine of the ten constants here are aliases, not literals:FirstNameMaxLength = SpeakerDTO.FirstNameMaxLength(:16) forwards toMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:21, which is where200is actually written.[Rubric §8, Data Architecture]assesses whether the storage schema and the domain rules can drift apart: everyHasMaxLengthinSpeakerConfigurationreads a constant from this class rather than a literal (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerConfiguration.cs:21,:25,:32,:36,:44,:48,:52,:56,:60), and so does every FluentValidation rule inSpeakerValidationRules(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:16,:27,:46,:65).[Rubric §15, Best Practices & Code Quality]: one edit in the Shared DTO moves the Razormaxlength, the validator message, the domain guard, and the column width together, because there is exactly one definition point. - Walkthrough
- Constants (
SpeakerInvariants.cs:16-43):FirstNameMaxLength200 (:16),LastNameMaxLength200 (:19),EmailMaxLength255 (:22),TagLineMaxLength500 (:25),ProfilePictureMaxLength2000 (:28),TwitterHandleMaxLength100 (:31),LinkedInUrlMaxLength2000 (:34),GitHubUrlMaxLength2000 (:37),WebsiteUrlMaxLength2000 (:40). The values are read fromSpeakerDTO(SpeakerDTO.cs:21,24,27,30,40,43,46,49,52). The tenth,AnswerValueMaxLength = 4000(:43), is the one written as a literal here, because it belongs to the child entity rather than to the speaker profile the DTO describes. EnsureFirstNameIsValid(string firstName, string source)(:45-48): aResult.CombineofCommonInvariants.EnsureStringIsNotEmpty(error codeSpeaker.FirstName.Empty) andCommonInvariants.EnsureStringMaxLength(Speaker.FirstName.TooLong). Combining rather than short-circuiting means a caller that submits an over-long empty-ish value gets both errors in one round trip. Thesourceparameter is the caller's method name (nameof(Create)), threaded into the error for tracing, andnameof(firstName)becomes the error target so a UI can bind the message to a field.EnsureLastNameIsValid(:50-53): identical shape againstLastNameMaxLength, error codesSpeaker.LastName.EmptyandSpeaker.LastName.TooLong.EnsureAnswerValueIsValid(string answerValue, string source)(:55-58): the same shape againstAnswerValueMaxLength, but the error codes are namespaced to the child entity,SpeakerQuestionAnswer.AnswerValue.EmptyandSpeakerQuestionAnswer.AnswerValue.TooLong, because that is the type a consumer sees the failure from.
- Constants (
- Why it's built this way: keeping lengths as
const intreachable from the Domain layer lets the outer layers depend inward on them (Clean Architecture) instead of each layer re-typing a number, and routing them through the Shared DTO lets the Blazor client, which cannot reference Domain, use the identical figure. Static methods returningResultkeep the aggregate free of exceptions on the validation path. - Where it's used:
Speaker.Create(Speaker.cs:140-141) andSpeaker.Update(Speaker.cs:218-219);SpeakerQuestionAnswer.Create(SpeakerQuestionAnswer.cs:52) andUpdateAnswer(SpeakerQuestionAnswer.cs:73). The constants additionally feedSpeakerConfiguration,SpeakerQuestionAnswerConfiguration(SpeakerQuestionAnswerConfiguration.cs:25), andSpeakerValidationRules. Covered directly bySpeakerInvariantsTests. - Caveats / not-in-source: only three of the ten constants have a matching
EnsureXxxmethod. Email, tag line, profile picture, the three URL fields and the Twitter handle are enforced by the application validator and the EF column width, not by a domain guard, so a caller that constructs aSpeakerthrough the domain factory alone (a test, or the Sessionize importer) can exceed those lengths and only fail atSaveChangesAsync.Biois a further step out: it has no constant here at all (theSpeakerDTO.BioMaxLengthof 4000 atSpeakerDTO.cs:37is not mirrored), andSpeakerConfiguration.cs:28-29maps it with noHasMaxLength, so the column is unbounded.
SponsorInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sponsors·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:13· Level 6 · class (static)
- What it is: the domain rule set for the
Sponsoraggregate. Seven field-length constants for a sponsor record and threeEnsureXxxguards the aggregate calls before it mutates anything. - Depends on:
CommonInvariants(Level 5),Result(Level 2), andSponsorDTO(Level 1) for the numbers. - Concept: the same three-layer constant chain
SpeakerInvariantsintroduces, with the same doc comment stating it (SponsorInvariants.cs:7-12). All seven constants forward toSponsorDTOrather than holding a literal.[Rubric §8, Data Architecture]: theHasMaxLengthcalls inSponsorConfigurationread them (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sponsors/SponsorConfiguration.cs:20,:30,:34,:38,:42,:46,:56), as do the rule classes inSponsorValidationRules(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:17,:34,:47,:64,:82,:95,:107). What is different from the speaker side is that two of the three guards here are optional-field guards, which the speaker set has none of. - Walkthrough
- Constants (
SponsorInvariants.cs:16-34):NameMaxLength200 (:16),LogoUrlMaxLength2000 (:19),DescriptionMaxLength2000 (:22),WebsiteUrlMaxLength2000 (:25),LinkedInUrlMaxLength2000 (:28),TwitterHandleMaxLength100 (:31),BoothNumberMaxLength50 (:34). The literals live onSponsorDTO(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:18,21,24,27,30,33,36). The 2000-char URL fields match the URL columns used elsewhere in the module; the 100-char handle and the 50-char booth number are the two deliberately tight ones. EnsureNameIsValid(string name, string source)(:42-45): the only required field check. AResult.CombineofCommonInvariants.EnsureStringIsNotEmpty(Sponsor.Name.Empty) andCommonInvariants.EnsureStringMaxLength(Sponsor.Name.TooLong).EnsureLogoUrlIsValid(string? logoUrl, string source)(:54-55): the optional-field shape. It is a single call toCommonInvariants.EnsureOptionalStringMaxLength, so absence is legal by construction and only a supplied value is length-checked (Sponsor.LogoUrl.TooLong). NoResult.Combinewrapper, because there is exactly one rule. The doc comment (:47-50) records why nothing else is checked: the value is a plain URL string with no upload pipeline behind it, so only the storage constraint applies.EnsureBoothNumberIsValid(string? boothNumber, string source)(:64-65): the same optional-field shape (Sponsor.BoothNumber.TooLong). The comment (:57-60) states the rule explicitly: a booth number is accepted even when the sponsor is not flaggedIsExhibitor, because the flag drives display and does not reject stored data. There is deliberately no cross-field invariant between the two.
- Constants (
- Why it's built this way: same rationale as
SpeakerInvariants. The optional-field helper is worth noting on its own: hand-writingif (value is null) return Result.Success()in each guard is the kind of repetition that eventually gets one branch wrong, so the framework shipsEnsureOptionalStringMaxLengthand the module rule reduces to one expression.[Rubric §15, Best Practices & Code Quality]. - Where it's used:
Sponsor.Create(Sponsor.cs:120-122) andSponsor.Update(Sponsor.cs:166-168); the constants additionally feedSponsorConfigurationandSponsorValidationRules. Covered directly bySponsorInvariantsTests. - Caveats / not-in-source: only three of the seven constants have a matching
EnsureXxxmethod.Description,WebsiteUrl,LinkedInUrlandTwitterHandlelengths are enforced by the application validator and the EF column width, not by a domain guard, so a caller that constructs aSponsorthrough the domain factory alone (a test, or the sample-data seeder) can exceed those four lengths and only fail atSaveChangesAsync.
Speaker
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:22· Level 7 · class (sealed)
What it is: the aggregate root for a conference speaker. It carries the profile (names, optional
Emailvalue object, bio, tag line, picture, four social links), owns two child collections, and holds the nullable link to an Identity user that grants a person speaker rights over their own sessions.Depends on:
AuditableAggregateRootEntity<TIdentifierType>(Level 4),IAuditedEntity(Level 0),SpeakerInvariants(Level 6),Email(Level 4),SpeakerCategoryItemandSpeakerQuestionAnswer(both Level 7, a mutual cycle: each child holds a back-navigation toSpeaker),SpeakerChanged,SpeakerCategoryItemChanged,SpeakerQuestionAnswerChanged(Level 2-3),ResultandError,DomainEntityStateandNavigationAttribute(Level 0). The identifier alias isSpeakerIdentifierType = System.Guid(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19), the only Guid identity in the Conference module.Concept introduced, the cross-context link maintained by events rather than a foreign key.
[Rubric §7, Microservices Readiness]assesses whether a module can be lifted out without a relational tether to a peer, and[Rubric §8, Data Architecture]assesses how a relationship is physically expressed.LinkedUserId(Speaker.cs:58) is a nullable scalar, not an EF navigation property, becauseUserlives in the Identity database and ADR-006 forbids a foreign key across that boundary. The two-way link (Speaker.LinkedUserIdon one side,User.LinkedSpeakerIdon the other) is kept consistent by publishing facts, not by a constraint: the uniqueness half is a filtered unique index in SQL (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerConfiguration.cs:63-65,HasFilter("[LinkedUserId] IS NOT NULL"), so many speakers may have no user but no user may have two speakers), and the propagation half isSpeakerLinkedToUser/SpeakerUnlinkedFromUserover the outbox (ADR-003).[Rubric §11, Security]and[Rubric §30, Compliance, Privacy and Data Governance]: the class is taggedIAuditedEntity(Speaker.cs:22), and the doc comment (:15-20) gives the reason rather than leaving it to convention. This row holds personal data and the link that grants a person authority over sessions, and it is written by three different actors (organizers, the speaker themselves, and the Sessionize sync), so a change history is what lets "who linked this account to this speaker" or a data-subject question be answered.Walkthrough
- Scalar state (
Speaker.cs:25-58), every setterprivate set:FirstName(:25),LastName(:28),Emailas anEmail?value object (:31),Bio(:34),TagLine(:37),ProfilePicture(:40),TwitterHandle(:43),LinkedInUrl(:46),GitHubUrl(:49),WebsiteUrl(:52),IsTopSpeaker(:55),LinkedUserId(:58).FullName(:61) is a computed$"{FirstName} {LastName}"with no backing column; EF is told to skip it withbuilder.Ignore(p => p.FullName)(SpeakerConfiguration.cs:68). - Owned collections (
:63-73): two privateList<T>backing fields exposed asIReadOnlyCollection<T>through.AsReadOnly(), each tagged[Navigation(IsCollection = true)]for the populator (ADR-002). A caller cannotAddtoSpeakerCategoryItemsorSpeakerQuestionAnswers; the aggregate methods below are the only doors. - Constructors: the EF parameterless one (
:76-80) assigns the two non-nullable names tostring.Emptyso they are definitely assigned before EF writes the columns; the private seven-parameter one (:82-98) can only be reached through the factory, which guarantees validation ran first. Create(...)(:116-171): note the ordering. The optional email is parsed into a value object first (:130-137), returningResult.Failure<Speaker>(emailResult.Errors)immediately if it is malformed, and only then are the name guards combined (:139-141). Identity resolution is the interesting part (:145,:161):SpeakerIdentifierTypeis a client-assignedGuid, so a Sessionize-imported speaker carries the id the feed gave it, whileId = id ?? (isIdValueGenerated ? default : Guid.NewGuid())generates one when the caller passes null. The comment above that line (:156-160) records why the null-coalescing exists at all: the earlierid!.Valuethrew "Nullable object must have a value" for organizer-created speakers and for the seeder, which both pass null. The four social links are set through an object initializer (:162-165) rather than the constructor. FinallyAddDomainEvent(new SpeakerChanged(DomainEntityState.Added, speaker.Id, speaker.FullName))(:168).Update(...)(:195-238): the same email-then-names validation order (:208-219), then eleven scalar writes (:223-233), thenSpeakerChanged(Updated, ...)(:235).LinkedUserIdis deliberately absent from the parameter list, and the remarks (:176-182) explain the risk that drove that:LinkUserandUnlinkUsercarry the uniqueness check and raise the events that keep Identity'sUser.LinkedSpeakerIdin sync, whereas a generic update raises onlySpeakerChanged, so writing the link here would silently desynchronize the two sides.Delete()(:251-267): capturesLinkedUserIdinto a local before anything else (:254), callsbase.Delete()(:256), and on success clears the link inside the Conference context (:261) and raisesSpeakerChanged(Deleted, Id, FullName, previousLinkedUserId)(:263). The captured value is the whole point: by the time the handler runs, the field on the entity is null, so the event has to carry it. The doc comment (:240-249) also records a deliberate non cascade: the child associations survive the soft-delete, because the Sessionize import reactivates them in place when the speaker returns and no cascade-restore counterpart exists, and junction reads follow the parent's visibility so the surviving children are not observable meanwhile.LinkUser(UserIdentifierType userId)(:272-286) andUnlinkUser()(:290-304): mirror guards. Linking an already-linked speaker fails withSpeaker.AlreadyLinked(:276-281), unlinking an unlinked one fails withSpeaker.NotLinked(:294-299). Both raiseSpeakerChanged(Updated, ...)on success.- Category-item children (
:314-390):AddSpeakerCategoryItem(:314) guards against a duplicate live association with_speakerCategoryItems.Exists(sci => !sci.IsDeleted && sci.CategoryItemId == categoryItemId)(:318), failing withSpeaker.CategoryItem.Duplicate; the!sci.IsDeletedhalf is what leaves room for the reactivation path.RestoreSpeakerCategoryItem(:352) takes the join instance rather than an id, because a soft-deleted row is excluded by the global query filter and so must be resolved by the caller (remarks,:345-349); it delegates to the framework'sRestoreChild<TChild, TChildId>(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:212) and then raisesSpeakerCategoryItemChanged(Added, ...)(:364), because the association re-enters the visible set.RemoveSpeakerCategoryItem(:374) goes throughRemoveChildOrNotFound(AuditableAggregateRootEntity.cs:156) and raises theDeletedvariant (:382). - Question-answer children (
:401-464):AddSpeakerQuestionAnswer(:401) has no duplicate guard, unlike the category-item side; validation lives in the child factory.UpdateSpeakerQuestionAnswer(:425) resolves through the privateGetSpeakerQuestionAnswerOrNotFoundhelper (:467, overGetChildOrNotFoundatAuditableAggregateRootEntity.cs:103), callsanswer.UpdateAnswerand re-raisesSpeakerQuestionAnswerChanged(Updated, ...)(:438).RemoveSpeakerQuestionAnswer(:448) mirrors the category-item remove. - Populator hooks (
:389,:463):SetSpeakerCategoryItemsandSetSpeakerQuestionAnswersareinternal, not public, and delegate to the framework'sSetItems(AuditableAggregateRootEntity.cs:60). Internal visibility is the compromise that lets the same-assembly populator rehydrate a cross-source load without opening bulk replacement to the application layer.
- Scalar state (
Why it's built this way:
[Rubric §4, Domain-Driven Design](assesses whether an aggregate owns a consistency boundary sized to a real transaction). Category items and question answers are edited as part of "editing a speaker", so they are children inside this root; sessions and events are not, so they are separate roots referenced by FK.[Rubric §6, CQRS and Event-Driven]: every mutation path ends in anAddDomainEventcall, which is what lets the delete cascade its cross-context consequence to Identity without Conference ever calling Identity synchronously.Where it's used: linked and unlinked by
LinkUserToSpeakerHandler(.../Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:57) andUnlinkUserFromSpeakerHandler(.../UnlinkUser/UnlinkUserFromSpeakerHandler.cs:43), and automatically byUserRegisteredHandleron an email match (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:86); imported and reconciled bySpeakerSyncStrategy(.../Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:157,161,174,178); hydrated bySpeakerNavigationPopulator(.../Speakers/SpeakerNavigationPopulator.cs:20,27); its delete observed bySpeakerDeletedHandler, which publishesSpeakerUnlinkedFromUseronly whenPreviousLinkedUserIdhas a value (.../Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:38-45); projected bySpeakerDTOMapper; persisted bySpeakerConfiguration; exposed bySpeakersController; rendered bySpeakerList,SpeakerDetail,SpeakerDashboard,PublicSpeakerListandPublicSpeakerDetail. Referenced by FK fromEventSpeaker,SessionSpeaker, and Identity'sUser. Unit-tested bySpeakerTests.
SpeakerCategoryItem
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:14· Level 7 · class (sealed)
- What it is: the join entity binding a
Speakerto aCategoryItem. It carries no data of its own beyond the two foreign keys, and it is how a speaker's tags (topic areas, and the locality classification the conference uses) are modeled: there is noSpeaker.Locationfield, the value is a category item joined through this row. - Depends on:
AuditableBaseEntity<TIdentifierType>(Level 3),IReactivatable(Level 3),Speaker(Level 7, back-navigation),Result(Level 2),IdValueGeneratedAttributeandNavigationAttribute(Level 0). AliasSpeakerCategoryItemIdentifierType = int(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18). - Concept introduced, reactivation as an explicit contract.
[Rubric §8, Data Architecture]assesses the interplay of soft delete with re-entry of the same logical row. The join implementsIReactivatable(:14), andReactivate()(:57) is a one-liner over the framework's protectedUndelete()(MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:89). The reason is in the doc comment (:51-55): the row carries the Sessionize-assigned category item id, so an association that disappears from the feed and later returns must be reactivated rather than duplicated by a second row (BR-135). Implementing the interface is what makes that intent discoverable and testable, instead of leaving the reactivation as an ad-hoc flag flip somewhere in the sync code. - Walkthrough
CategoryItemId(:17), private set: the FK to the tag.Speaker?(:21) is the back-navigation tagged[Navigation].SpeakerId(:24) is getter-only, written by EF from the shadow FK rather than by the domain.Create(SpeakerCategoryItemIdentifierType? id, CategoryItemIdentifierType categoryItemId)(:37-49): no validation at all, because a pure FK pair has nothing to validate. It reads the[IdValueGenerated]marker (:13,:41) throughMMCA.Common/Source/Core/MMCA.Common.Domain/Extensions/EntityTypeExtensions.cs:19and setsId = isIdValueGenerated ? default : id!.Value(:45), so the database assigns the key. No domain event is raised here: the parent aggregate emitsSpeakerCategoryItemChanged, which keeps event ownership with the root.Reactivate()(:57) andSetSpeaker(Speaker? speaker)(:61): the two capability hooks. Note the asymmetry with the parent,SetSpeakerispublic(the populator lives in another assembly), whereas the collection setters onSpeakerareinternal.
- Why it's built this way: modeling a speaker's locality and topics as join rows to shared
CategoryItemvalues rather than as string columns means the same vocabulary serves sessions and speakers, and a rename happens in one place.[Rubric §4, Domain-Driven Design]: the join is a child of the speaker root, not a root of its own, because it has no lifecycle independent of the speaker. - Where it's used: created, restored and removed only through
Speaker(Speaker.cs:314,352,374); reconciled against the feed bySpeakerSyncStrategy.SyncCategoryItems(.../RefreshFromSessionize/SpeakerSyncStrategy.cs:145-164), which is the only caller ofRestoreSpeakerCategoryItem; projected bySpeakerCategoryItemDTOMappertoSpeakerCategoryItemDTO; persisted bySpeakerCategoryItemConfiguration; read bySpeakerLocalityHelper. Unit-tested bySpeakerCategoryItemTests.
SpeakerQuestionAnswer
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:13· Level 7 · class (sealed)
- What it is: the child entity holding one speaker's answer to one
Question, for example a dietary preference or a shirt size collected in the call-for-papers form. - Depends on:
AuditableBaseEntity<TIdentifierType>(Level 3),SpeakerInvariants(Level 6),Speaker(Level 7, back-navigation),Result(Level 2),IdValueGeneratedAttributeandNavigationAttribute(Level 0). AliasSpeakerQuestionAnswerIdentifierType = int(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:20). - Concept: the same child-entity shape as
SpeakerCategoryItem, with one difference that is worth naming. This child does carry a mutable payload (AnswerValue), so it owns a validatingUpdateAnswermethod and re-runs the guard on every write. It does not implementIReactivatable, because the sync path updates an existing live answer in place rather than resurrecting a deleted one. - Walkthrough
QuestionId(:16) andAnswerValue(:19), both private set;Speaker?(:23) tagged[Navigation];SpeakerId(:26) getter-only. The EF constructor (:29) assignsAnswerValue = string.Emptyfor definite assignment; the private two-parameter constructor (:31-37) is reachable only from the factory.Create(id, questionId, answerValue)(:46-64): validates throughSpeakerInvariants.EnsureAnswerValueIsValidinside aResult.Combine(:51-52), which is a single-argument combine, a shape that reads oddly but leaves room for a second rule without restructuring. Then the[IdValueGenerated]lookup (:12,:56) andId = isIdValueGenerated ? default : id!.Value(:60). No domain event: the parent emitsSpeakerQuestionAnswerChanged.UpdateAnswer(string answerValue)(:71-80): re-validates with the identical guard (:73) before assigning (:77). Validating on update as well as on create is the point of routing both through the invariants class: an entity that can only be constructed valid but then freely mutated is not actually protected.SetSpeaker(Speaker? speaker)(:84): the populator hook, public for the same reason as on the sibling join.
- Why it's built this way:
[Rubric §4, Domain-Driven Design]: keeping the answer text guarded inside the entity (rather than validating it once in a request validator) means every write path, including the Sessionize import, which never touches a FluentValidation validator, is held to the same 4000-character rule. - Where it's used: added, updated and removed only through
Speaker(Speaker.cs:401,425,448); populated from the feed bySpeakerSyncStrategy.SyncQuestionAnswers(.../RefreshFromSessionize/SpeakerSyncStrategy.cs:166-181), which updates a live answer in place when one exists for the question and adds otherwise; projected bySpeakerQuestionAnswerDTOMappertoSpeakerQuestionAnswerDTO; persisted bySpeakerQuestionAnswerConfiguration. Unit-tested bySpeakerQuestionAnswerTests.
Sponsor
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sponsors·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:18· Level 8 · class (sealed)
What it is: the aggregate root for a conference sponsor or exhibitor. A sponsor belongs to exactly one
Event, carries aSponsorTierthat drives its public placement, and optionally staffs an expo booth (class doc,Sponsor.cs:12-16).Depends on:
AuditableAggregateRootEntity<TIdentifierType>(Level 4),SponsorInvariants(Level 6),SponsorTier(Level 0),Event(Level 7, navigation only),SponsorChanged(Level 3),Result(Level 2),DomainEntityState(Level 0),IdValueGeneratedAttribute(Level 0),NavigationAttribute(Level 0). The identifier alias isSponsorIdentifierType = int(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:21).Concept: the aggregate-root mechanics are taught on
Category; this section covers what is different here.[Rubric §4, Domain-Driven Design](assesses whether each aggregate owns a consistency boundary sized to a real transaction):Sponsoris a flat, childless aggregate. It has no owned collection, soDelete()has nothing internal to cascade to, and its whole invariant surface is field validation. That flatness is exactly why it is a separate root instead of a child ofEvent: sponsors are sold and edited on their own cadence, and loading an event to add one would widen the event's transaction for no benefit.[Rubric §8, Data Architecture](assesses identity generation and relational shape): the class carries[IdValueGenerated](:17), so sponsor IDs come from the database. The doc comment (:15) gives the reason in one clause: "sponsors are sold, not imported from Sessionize". WhereSpeakerhas to accept an externally supplied Guid because the Sessionize import supplies one,Sponsornever does. Relationally it is the many side of a plain FK toEvent(SponsorConfiguration.cs:62-65) with a soft-delete-filtered index onEventId(SponsorConfiguration.cs:67-68), andTieris persisted throughHasConversion<int>()(SponsorConfiguration.cs:25-27) so the tier ordering is a plain column sort.Walkthrough
- Scalar state (
:21-58), every setterprivate setso mutation can only happen throughUpdate:Name(:21),Tier(:24),LogoUrl(:27),Description(:30),WebsiteUrl(:33),LinkedInUrl(:36),TwitterHandle(:39),Sort(:42, the display order within the tier),EventId(:45),IsExhibitor(:52),BoothNumber(:58, kept even whenIsExhibitoris false, per the comment at:54-57). - Navigation (
:48-49):[Navigation] public Event? Event { get; private set; }, assigned after a cross-source load by the populator through the publicSetEvent(Event? @event)method (:202) rather than by a public setter (ADR-002). It is not part of the aggregate's own invariants. - EF constructor (
:61): private, parameterless, assigningName = string.Emptyso the non-nullable field is definitely assigned before EF writes the columns. - Private constructor (
:63-87): takes all eleven values and assigns them. Being private, it can only be reached through the factory, which guarantees validation ran first. Create(...)(:105-136): the canonical validate, then resolve identity, then construct, then emit shape.Result.Combineof the three guards (:119-122), returningResult.Failure<Sponsor>(result.Errors)on any failure (:123-124) so all validation errors surface at once rather than the first one.typeof(Sponsor).IsIdValueGenerated(:126) reads the[IdValueGenerated]marker by reflection (MMCA.Common/Source/Core/MMCA.Common.Domain/Extensions/EntityTypeExtensions.cs:19).- Construction with
Id = isIdValueGenerated ? default : id!.Value(:130): the caller may pass an explicit ID, but with the marker present it is ignored and the database assigns one. AddDomainEvent(new SponsorChanged(DomainEntityState.Added, sponsor.Id, sponsor.Name))(:133). The ID captured here isdefaultunder DB-generated identity, since the row does not exist yet.
Update(...)(:153-186): re-runs the same three guards (:165-168), returns the combined failure unchanged (:169-170), then assigns the ten mutable fields (:172-181) and emitsSponsorChanged(Updated, ...)(:183).EventIdis absent from the parameter list on purpose: the doc comment (:140) states that moving a sponsor between events is a create plus a delete, not an update, so the owning-event relationship is immutable for the lifetime of the row.Delete()(:190-198): overrides the base soft-delete, callsbase.Delete()first (:192), and only raisesSponsorChanged(Deleted, ...)when that succeeded (:194-195). No child cascade, because there are no children.
- Scalar state (
Why it's built this way:
[Rubric §6, CQRS and Event-Driven](assesses whether state changes announce themselves): all three lifecycle transitions raise the sameSponsorChangedrecord differing only byDomainEntityState, so a single handler can invalidate the sponsor output cache for any change. Soft delete rather than row removal follows ADR-005; the aggregate never hard-deletes itself.Where it's used: created by
CreateSponsorHandlerand edited throughSponsorUpdateApplier; cascade-deleted byEventCascadeDeletionDomainService; projected toSponsorDTObySponsorDTOMapper; hydrated bySponsorNavigationPopulator; exposed over REST bySponsorsController; mapped bySponsorConfiguration; seeded byConferenceModuleDbSeeder(.../Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:381); rendered bySponsorList,SponsorDetailandSponsorCreate. Unit-tested bySponsorTests.Caveats / not-in-source: the
Eventnavigation's doc comment (:47) describes it as being there "for public visibility filtering", but the public sponsor filter does not join through it:GetPublicSponsorFilterHandlerresolves published event ids first and returns apublishedEventIds.Contains(s.EventId)criteria instead (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:29-30), which its own doc comment (:13-14) justifies as keeping the criteria translatable on any engine. Treat the navigation as available for populators and detail screens, not as the visibility path.
⬅ Aspire Orchestration & Service Defaults • Index • ADC Conference - Application & Use Cases ➡