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
Category/CategoryItem taxonomy (tracks, levels, formats), and the Question/answer machinery
that captures structured metadata about events, sessions, and speakers. Five aggregate roots, a
sixth AI-scoring aggregate, a dozen child entities, the static invariant classes that guard their
business rules, the domain events every mutation raises, a pure domain service that
coordinates cross-aggregate cascade deletes, and, across the package boundary in
MMCA.ADC.Conference.Shared, the DTO contracts, the cross-module service interfaces the
Engagement module calls, the integration events that keep the User-to-Speaker link 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 and DomainEntityState (G02),
the EntityChangedEvent<TIdentifierType>
event base 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 all five aggregates.
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 inner
ring that depends only on MMCA.Common.Domain/.Shared and knows nothing about EF, 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
integration events Identity subscribes to, and the feature-flag, permission, and status constants.
Shared is the package every other layer, including the Blazor WebAssembly UI, can reference without
dragging in the domain. The AssemblyReference/ClassReference
anchor pair in Domain
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/AssemblyReference.cs:5,11) is the
trivial type Scrutor and the architecture-fitness tests pin to when they need to name the Conference
domain assembly.
The five 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. The
Conference context has five roots, all deriving from
AuditableAggregateRootEntity<TIdentifierType>
so they inherit soft-delete, audit stamping, and the buffered DomainEvents collection:
Event(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:17) ownsRoom(the physical rooms),EventSpeaker(the speaker roster, a join toSpeakerby ID), andEventQuestionAnswer(event-level structured answers). ItsIdis database-generated (marked[IdValueGenerated],Event.cs:16).Session(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:16) ownsSessionSpeaker,SessionCategoryItem, andSessionQuestionAnswer. Critically, a Session references itsEventandRoomby scalar FK (EventIdatSession.cs:60,RoomIdatSession.cs:63): they are separate aggregates, even though the model exposesEvent/Roomnavigations (Session.cs:67,71, both[Navigation]-decorated with public setters 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.Speaker(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:15) ownsSpeakerCategoryItemandSpeakerQuestionAnswer, holds an optionalEmailvalue object (Speaker.cs:24), and carries the cross-moduleLinkedUserIdFK to an IdentityUser(Speaker.cs:55). SpeakerIds are Sessionize-assigned GUIDs, with a fallback toGuid.NewGuid()for organizer-created speakers (see the in-code BR note atSpeaker.cs:146-151).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.Question(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:15) is a flat aggregate (no children); its answers live on the other aggregates as*QuestionAnswerjoin entities, keyed byQuestionId.
A sixth root, SessionAiScore
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:12), is an
AI-generated scorecard for a session (seven 1.0-to-10.0 scores: an overall plus six per-criteria, at
SessionAiScore.cs:17-36, together with the model's reasoning and identifier, each score
range-guarded at SessionAiScore.cs:133-140), stored one-per-session and replaced on re-scoring. 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 scalar SessionId (SessionAiScore.cs:15) 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.
The aggregate shape, taught once
Open any of the five 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:20): 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:62becomesRooms => _rooms.AsReadOnly()atEvent.cs:66), each decorated[Navigation(IsCollection = true)]so the navigation-populator machinery (G11) knows how to eager-load it. Children can only be added or removed throughAddRoom/RemoveRoom-style methods that enforce invariants (for example duplicate-name rejection atEvent.cs:330-337). - A private EF constructor (
Event.cs:81, for materialization) plus a private state constructor (Event.cs:87) used only by the factory. - A static
Create(...)factory returningResult<T>(Event.cs:125): it validates invariants viaResult.Combine(...)before constructing anything (Event.cs:138-143), so an invalid aggregate is unrepresentable, then raises anAddeddomain event (Event.cs:162). TheisIdValueGenerated ? default : id!.Valuedance (Event.cs:145,158) reconciles database-generated IDs with explicitly supplied ones. - Mutator methods (
UpdateatEvent.cs:182,Publish/UnpublishatEvent.cs:219,239,LinkUser/UnlinkUseron Speaker atSpeaker.cs:250,268) that re-validate, mutate, and raise anUpdatedevent. - An overridden
Delete()(Event.cs:275) that callsbase.Delete()(the soft-delete from G02), then cascade-soft-deletes each owned child (Event.cs:281-300) and raises aDeletedevent (Event.cs:302). Soft-delete is the default everywhere ([Rubric §8, Data Architecture]: theIsDeletedflag plus EF global query filters, never a hardDELETE). internal SetX(...)methods (Event.cs:409,469,546) 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 four *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,
SessionInvariants, SpeakerInvariants,
CategoryInvariants, 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 and add domain-specific rules: SessionInvariants carries the
length constants shared with the EF configuration so the domain rule and the column constraint can
never drift
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:12-34),
plus the BR-49 status-eligibility check (SessionInvariants.cs:78) and the BR-122 zero-duration guard
(SessionInvariants.cs:95); QuestionInvariants validates the free-text enum-like fields against
allow-lists (QuestionInvariants.cs:28-34,68) and, for answers, checks each answer value against its
question type (Rating 1-5, Text max 2000, Email format, QuestionInvariants.cs:115); CategoryInvariants
enforces case-insensitive uniqueness of an item name within its category (BR-138, invoked from
Category.cs:137). Centralizing each rule as a named, side-effect-free method is what makes the domain
exhaustively unit-testable ([Rubric §14, Testability]) and keeps the ubiquitous language explicit:
the error codes ("Event.AlreadyPublished" at Event.cs:224, "Session.Duration.Invalid" at
SessionInvariants.cs:100) are the business vocabulary. The recurring // BR-NN comments are
traceability links back to specifications.md.
A nuance worth flagging: the Status field on Session is free-text, imported verbatim from Sessionize
(Session.cs:31), and SessionStatuses
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:8) is the
constant catalogue of the recognized values plus the IsEligible(...) rule (everything except
Declined/Cancelled is eligible for public display, bookmarking, and feedback, BR-49,
SessionStatuses.cs:45-47). Using const string values instead of a C# enum means an unrecognized
Sessionize status does not break deserialization; it 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(...). The
events come in two shapes. The aggregate-level ones, EventChanged,
SessionChanged, SpeakerChanged,
CategoryChanged, QuestionChanged, derive from
EntityChangedEvent<TIdentifierType>
and carry the DomainEntityState
(Added/Updated/Deleted) plus a friendly label. The child-level ones, RoomChanged,
EventSpeakerChanged, SessionSpeakerChanged,
SessionCategoryItemChanged, the *QuestionAnswerChanged set, and the
rest, carry both the parent and child IDs (for example RoomChanged(state, Id, room.Id, room.Name) at
Event.cs:347) so a consumer can target the precise change and the module can invalidate the right
output-cache tag. 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.
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 is worth noting for the cross-context link: Speaker.Delete()
captures the previous LinkedUserId before clearing it and stuffs it into the Deleted
SpeakerChanged event (Speaker.cs:232,241), whose PreviousLinkedUserId payload
field 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), but Sessions are a separate aggregate (referenced by EventId,
not owned). Putting a List<Session> inside Event would violate the aggregate boundary. The answer
is a domain service, IEventCascadeDeletionDomainService
and its implementation EventCascadeDeletionDomainService
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:11),
a pure, infrastructure-free coordinator that takes the pre-fetched Event plus its already-loaded
Session collection and orchestrates the deletes: soft-delete each session first (BR-55 cascades to
its children), then soft-delete the event (BR-72 cascades to rooms, speakers, and answers)
(EventCascadeDeletionDomainService.cs:14-24). 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 only Level-7/8 type in the chapter precisely because it depends on
two aggregates.
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,
ConferenceCategoryDTO, QuestionDTO, RoomDTO,
and the per-child join DTOs, plus the speaker-facing feedback shapes
(SessionFeedbackDTO and its RatingQuestionSummary/TextQuestionResponses
members). They carry the entity's Id (via the framework's IBaseDTO contract) and init-only
properties: read contracts, immutable after construction.
A distinct and more interesting subgroup is the DecisionSupport namespace: read models built
purely to help an organizer curate a conference. SessionSelectionDashboardDTO
is the composite; for one event it aggregates a CategoryDistributionDTO
(how sessions spread across tracks and levels), a SpeakerSessionOverlapDTO
(speakers with multiple submissions), a ContentSimilarityDTO (near-duplicate
talks), per-tier SpeakerLocalitySummary counts (the Atlanta-versus-elsewhere
breakdown that drives the local-speaker preference, tracked as a CategoryItem rather than a Speaker
field), and a list of SessionAiScoreDTO. The AI scores are produced by an
Anthropic-backed scoring service (in Conference.Infrastructure, outside this chapter) and persisted
as the SessionAiScore aggregate;
ScoreEventSessionsResultDTO reports a batch run's success and failure
counts. This organizer workflow is guarded by the conference:session-selection:manage capability
permission catalogued in ConferencePermissions (ConferencePermissions.cs:30),
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, wired
through the [FeatureGate]/IFeatureGated mechanism from
G05 ([Rubric §10, Cross-Cutting Concerns], ADR-031) on the
RefreshFromSessionizeCommand, not on the scoring or dashboard handlers.
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 capability permissions (conference:events:manage,
conference:sessions:manage, and so on, ConferencePermissions.cs:12-30), the stable string
identifiers endpoints require via [HasPermission(...)] rather than by role name. The All and
ContentManagement subsets (ConferencePermissions.cs:33,49) let a role grant an entire capability
set or the narrower session-catalog-curation slice (sessions, speakers, 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, not scattered across controllers.
CurrentEventSelector
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:10) 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:38-52). It shares the exact live-window math the backend enforces:
StartDate at 00:00 local through EndDate + 1 day at 00:00 local, converted from the event's IANA
time zone to UTC, with an unknown time-zone id degrading to treating the local dates as UTC
(CurrentEventSelector.cs:64-85). Because it 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.
Crossing the module boundary: contracts, stubs, and integration events
Conference does not live alone. Three connection points 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, eligible status) and to enumerate a session's IDs by event. It depends on the
ISessionBookmarkValidationServiceinterface (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/ISessionBookmarkValidationService.cs:10), 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,DisabledSessionBookmarkValidationServiceis registered as a null-object stub that approves every validation and returns an empty ID set: graceful degradation rather than a missing-dependency crash.Synchronous live-layer validation (inbound). The Engagement conference-day live layer (polls and session Q&A) asks Conference whether a target event is published and inside its live window, and, for a session, who the assigned speakers are, whether it is a plenum, and the event's question moderation default. That contract is
IEventLiveValidationService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/IEventLiveValidationService.cs:11), returningEventLiveInfoandSessionLiveInfosnapshots so the caller never references a Conference domain entity. The moderation default is theQuestionModerationDefaultenum (Pending/Approved, BR-233,QuestionModerationDefault.cs:7) carried on theEvent(Event.cs:54). Its disabled stub,DisabledEventLiveValidationService, deliberately fails open: it reports the event as published with an always-open window (DisabledEventLiveValidationService.cs:25-42) so the live-layer handlers can run without an in-process Conference module, at the cost of skipping the published and live-window checks until the host is wired to the Conference gRPC adapter.Asynchronous speaker linking (outbound). 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:20), integration events extendingBaseIntegrationEvent. Identity subscribes and sets or clearsUser.LinkedSpeakerId, so the next JWT refresh carries thespeaker_idclaim (BR-209). This is the eventually-consistent replacement for what used to be a direct cross-module service call: the User-to-Speaker bidirectional link now survives the service split because it travels as an event over the broker (ADR-006/ADR-008).
End-to-end: one organizer action
To see the chapter cooperate, follow an organizer changing a room on a session's parent event. The
application handler loads the Event aggregate (with its Rooms hydrated by the navigation
populator), calls event.UpdateRoom(...) (Event.cs:363), which routes through GetRoomOrNotFound
(returning a NotFound Result if the room is gone,
Event.cs:372-375), delegates to the child's own Room.Update(...) (which validates its
invariants), and on success raises a RoomChanged Updated event (Event.cs:381).
The handler calls SaveChangesAsync; the interceptor writes the RoomChanged to the outbox in the
same transaction; in-process dispatch busts the relevant output-cache tags so the next read 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-006/007/008 (database-per-service, gRPC, service topology), ADR-020 (permission-based
authorization), and ADR-031 (feature flags) are the primary references; the business rules themselves
are catalogued in MMCA.ADC/specifications.md.
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 to theMMCA.ADC.Conference.Domainassembly. No behavior.
| Type | File:Line | Notes (what differs) |
|---|---|---|
AssemblyReference |
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/AssemblyReference.cs:5 |
static class exposing Assembly (the typeof(AssemblyReference).Assembly, line 7) and AssemblyName (line 8) |
ClassReference |
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/AssemblyReference.cs:11 |
one-line empty public class ClassReference { }, a generic-argument handle for scanners that want a type, not an Assembly |
- Depends on: nothing first-party (
System.Reflectiononly,AssemblyReference.cs:1). - Concept introduced, the assembly-marker pattern.
[Rubric §2, Design Patterns](assesses whether patterns are idiomatic and solve a real problem): instead of hard-coding an assembly name string, scanners take atypeof(...)from a type they know lives in the target assembly.AssemblyReference.Assembly(AssemblyReference.cs:7) cachestypeof(AssemblyReference).Assembly;ClassReference(AssemblyReference.cs:11) exists so a generic API likeAddSomething(typeof(ClassReference))has a non-static type to point at. Every layer of every ADC module ships this same pair (see the same pair in group-18 Conference.Application, group-19 Conference.Infrastructure, and group-20 Conference.API), so the module loader and EF/handler discovery code is uniform across the codebase. - Walkthrough:
AssemblyReference.Assembly(AssemblyReference.cs:7) is astatic readonly Assembly;AssemblyName(AssemblyReference.cs:8) is itsGetName().Name ?? string.Empty.ClassReference(AssemblyReference.cs:11) has no members. - Why it's built this way: a
typeof()handle is refactor-safe (rename the assembly and the reference still compiles) where a magic string is not. - Where it's used: Scrutor assembly scanning that registers domain-event handlers, EF entity configurations, and the like; the
ModuleLoaderreflection pass.
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. Currently one constant:
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 §10, Cross-Cutting Concerns](assesses how cross-cutting behavior like flags/config is centralized rather than scattered). The constant value matches a key under the"FeatureManagement"configuration section (doc commentConferenceFeatures.cs:3-7) and is consumed withMicrosoft.FeatureManagementvia[FeatureGate]attributes and theIFeatureGatedmarker. Centralizing the string here means the flag name appears once; a typo cannot silently split a flag into two. The"{Module}.{Feature}"naming convention keeps flags from different modules unambiguous in one config file. - Walkthrough: a single
public const string(ConferenceFeatures.cs:15). The doc comment (ConferenceFeatures.cs:10-14) records the runtime contract: when the flag is disabled,RefreshFromSessionizeCommandshort-circuits with a failure result and organizers must manage event data manually. - Why it's built this way: isolating Sessionize sync behind a flag lets organizers turn the integration off during a Sessionize API maintenance window without redeploying.
- Where it's used: checked via
IFeatureManagerat the top of theRefreshFromSessionizeCommandhandler (group-18).
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 via
[HasPermission(...)]instead of role names. Sevenmanagecapabilities (conference:events:manage,:sessions:manage,:speakers:manage,:rooms:manage,:categories:manage,:questions:manage,:session-selection:manage) plus two curated sets. - 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 across controllers). This is ADC's use of the framework's permission mechanism (seeIPermissionRegistryandHasPermissionAttributein G08). The values are deliberately stable strings (the doc comment,ConferencePermissions.cs:3-8, notes they may appear in tokens or logs). The class also exposes twoIReadOnlyList<string>groupings:All(every Conference permission, for granting an entire capability set to a role at once) andContentManagement(the curation subset,SessionsManage+SpeakersManage+CategoriesManage).ContentManagementis the load-bearing one: per its doc comment (ConferencePermissions.cs:44-48), a content-editor role (seeRoleNames) holds exactly the catalog-curation capabilities without event structure, rooms, questions, or session selection, a distinction the registry expresses centrally that scattered[Authorize(Roles = ...)]lists could not. - Walkthrough: seven
public const stringcapability fields (ConferencePermissions.cs:12-30), thenAll(ConferencePermissions.cs:33-42) andContentManagement(ConferencePermissions.cs:49-54) as collection-expressionIReadOnlyList<string>properties. - Why it's built this way: a per-module catalog keeps each module's capabilities self-contained; pairing the constants with named subsets means the role-to-permission grants in the module's registration read declaratively (
[.. All]for the organizer,[.. ContentManagement]for the content editor). - Where it's used: referenced by every Conference controller's
[HasPermission(...)]attributes and by the role-to-permission grants inAddModuleConferenceAPI(which callsAddPermissions).
SessionStatuses
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:8· Level 0 · class (static)
- What it is: the catalog of the six recognized Sessionize session-status strings, plus a behavioral predicate
IsEligible()that gates public display, attendee bookmarking, and post-session feedback (BR-49).Session.Statusis a free-text field imported from Sessionize; this class is the single place in the domain that gives those strings behavioral significance. - Depends on: nothing first-party (
System.StringComparisononly). - Concept introduced, avoiding premature enumeration for external data.
[Rubric §4, Domain-Driven Design](assesses a model that mirrors the business and uses ubiquitous language) and[Rubric §8, Data Architecture](assesses deliberate handling of externally-sourced data). Sessionize can return a status string not yet in this list; usingconst stringvalues instead of a C#enummeans an unknown status does not break deserialization, the domain simply treats it as unrecognized rather than eligible.AllKnownStatuseslets callers populate filter dropdowns without re-listing the constants by hand. - Walkthrough
- Six
const stringvalues (SessionStatuses.cs:11-26):Accepted,Waitlisted,AcceptQueue("Accept_Queue"),Nominated,DeclineQueue("Decline_Queue"),Declined. Note two of the literal values carry an underscore the C# identifier does not. AllKnownStatuses(SessionStatuses.cs:31-39): astatic readonly IReadOnlyList<string>collection-expression of all six, for organizer filter UIs.IsEligible(string? status)(SessionStatuses.cs:45-47): returnstrueunlessstatusequals"Declined"or the literal"Cancelled"(case-insensitive,StringComparison.OrdinalIgnoreCase)."Cancelled"is deliberately not inAllKnownStatuses, it is a defensive guard for a Sessionize value seen in the wild but not part of the formal six.
- Six
- Why it's built this way: keeping the eligibility predicate in the domain (not the application or API layer) ensures every consumer, command handler, query filter, and UI visibility check applies the identical definition. Adding a new ineligible status is a one-line edit here, not a grep across handlers.
- Where it's used:
SessionInvariants.EnsureStatusIsEligible(same group), the Sessionize sync strategy's pre-import eligibility check, the session-selection dashboard's status bucketing, the public session-list query filter, and the bookmark/feedback guards (groups 18-22).
SessionInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:10· Level 4 · class (static)
- What it is: the domain invariant-rule library for the
Sessionaggregate and its children: title non-empty/max-length, answer-value non-empty/max-length, a not-a-service-session guard (BR-91), status eligibility for engagement actions (BR-49), and an end-after-start time check (BR-122). The length constants declared here are referenced by both domain validation and EF configuration so a column constraint and a domain rule can never silently diverge (doc comment,SessionInvariants.cs:6-9). - Depends on:
CommonInvariants(Level 3),Result/Error(group-01), andSessionStatuses(same group, Level 0). - Concept: the static-invariant-class pattern (methods returning
Result, combined withResult.Combine) was introduced for the framework inCommonInvariants.[Rubric §4, Domain-Driven Design](invariants live in the domain, not in handlers or the database). Two members are worth calling out:EnsureStatusIsEligible(SessionInvariants.cs:78-85) delegates toSessionStatuses.IsEligible(status), so the eligibility whitelist lives in the Level-0 catalog and is reused here: there is one definition of "eligible".EnsureEndsAtIsAfterStartsAt(SessionInvariants.cs:95-107) guards zero-duration sessions:endsAt <= startsAtfails with an invariant error, and both nullable parameters must be non-null for the check to run (null means "not yet scheduled").
- Walkthrough: eight
const intlength constants (SessionInvariants.cs:13-34) plus twostatic readonlyrange constants (SessionInvariants.cs:41-44), the latter theManualIdRangeStart(999_999_000) /ManualIdRangeEnd(999_999_999) reserved id window for sessions not imported from Sessionize (organizer-created and seeded samples sit above any real Sessionize id so they never collide, the int PK is the Sessionize id). Then fiveEnsure*methods returningResult:EnsureTitleIsValid(line 46),EnsureAnswerValueIsValid(line 51),EnsureNotServiceSession(line 62),EnsureStatusIsEligible(line 78), andEnsureEndsAtIsAfterStartsAt(line 95), each tagging errors with asourcestring for tracing. - Why it's built this way: sharing
TitleMaxLengthetc. betweenHasMaxLength(SessionInvariants.TitleMaxLength)in EF config and the domain check keeps schema and rule in lockstep; the reserved id range encodes the Sessionize-id-is-the-PK design decision in one documented place. - Where it's used:
Session.Create/UpdateandSessionQuestionAnswer.Create/UpdateAnswer; the application-layer Session validators; and EF configurations for column lengths (groups 18-19).
SessionAiScore
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:12· Level 5 · class (sealed)
- What it is: an aggregate root storing the AI-generated score for one session across seven criteria (topic relevance, description quality, novelty, actionable takeaways, depth/insight, credibility/experience, and an overall score), plus the model's free-text
Reasoningand theModelUsedidentifier. One score per session; re-scoring replaces the existing record viaUpdate. - Depends on:
AuditableAggregateRootEntity<TIdentifierType>(bound toSessionAiScoreIdentifierType),IdValueGeneratedAttribute,Result/Error(group-01). - Concept: the factory-method-returns-
Result<T>aggregate pattern (private ctor +static Result<T> Create) was introduced in group-02.[Rubric §4, DDD]and[Rubric §11, Security]overlap interestingly here: validating AI output inside the domain (range 1.0-10.0) means a model response is checked before it can reach the database, so an out-of-range or hallucinated value cannot enter storage even though the data originates from an external model. The[IdValueGenerated]attribute (SessionAiScore.cs:11) marks the id as database-generated. - Walkthrough
- Ten
private setproperties (SessionAiScore.cs:15-42): the FKSessionId, sevendecimalscores (OverallScore,TopicRelevanceScore,DescriptionQualityScore,NoveltyScore,ActionableTakeawaysScore,DepthOrInsightQualityScore,CredibilityExperienceScore), and theReasoning/ModelUsedtext. The EF parameterless ctor (SessionAiScore.cs:45-49) seedsReasoning/ModelUsedtostring.Emptyto satisfy non-nullable init. Create(SessionAiScore.cs:54-92): combines sevenEnsureScoreInRangechecks viaResult.Combine; on failure returnsResult.Failure<SessionAiScore>(result.Errors), otherwise constructs withId = default(SessionAiScore.cs:80, the DB fills it).Update(SessionAiScore.cs:97-131): re-runs the same seven range checks, then replaces every score field and the reasoning/model, used when re-scoring.EnsureScoreInRange(SessionAiScore.cs:133-140): a private helper using the C# patternscore is >= 1.0m and <= 10.0m, shared by bothCreateandUpdate(no duplication).- Note: no domain events are emitted on scoring (there is no
AddDomainEventcall in the file), because there are no domain-event consumers for score changes, so persistence alone suffices.
- Ten
- Why it's built this way: self-contained range validation in the domain is the last line of defense against bad AI data; replacing (not appending) on re-score keeps exactly one current score per session.
- Where it's used: created/updated by the
ScoreEventSessionshandler (group-18), which calls the Anthropic scoring service (group-19); read by the organizer session-selection dashboard (group-21). - Caveats / not-in-source: the
SessionAiScoreIdentifierTypealias is defined inConference.Shared; its underlying type (int vs Guid) is not visible in this file.
Session
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:16· Level 6 · class (sealed)
What it is: the richest aggregate root in the Conference module.
Sessionowns three child collections (SessionSpeaker,SessionCategoryItem,SessionQuestionAnswer) and coordinates their full lifecycle: creation, update, cascade soft-deletion, and a domain event for every structural change. Session IDs are Sessionize-assigned, not database-generated.Depends on:
AuditableAggregateRootEntity<TIdentifierType>(bound toSessionIdentifierType),DomainEntityState,NavigationAttribute,EntityTypeExtensions(theIsIdValueGeneratedextension),Result/Error(group-01); the domain entitiesEventandRoom(reference navigations);SessionInvariants; the child entities 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-entity lifecycle management). An aggregate root is the only entry point for mutations inside its boundary: nothing outsideSessioncan add or remove aSessionSpeakerdirectly, all such operations go throughSession.AddSessionSpeaker/RemoveSessionSpeaker, etc. This guarantees three things at once:- Invariant checking:
AddSessionSpeakerrejects a duplicate speaker (Session.cs:308-315). - Event emission: 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:
Delete()(Session.cs:263) soft-deletes each non-deleted child before emittingSessionChanged(Deleted), implementing BR-55 at the domain level rather than via a DB cascade or handler glue.
The
privateconstructors (Session.cs:99and:101) plusstatic Result<Session> Create(Session.cs:150) enforce that aSessionis only ever built through a validated, event-emitting path.- Invariant checking:
Walkthrough
- Properties (
Session.cs:19-63): fifteenprivate setdomain fields, callers must use the mutation methods, not direct assignment.Titleis non-nullable;LiveUrl/RecordingUrl(Session.cs:47,51) each carry a justified[SuppressMessage(..."CA1056"...)](they hold astringfrom Sessionize rather than aUri);EventId/RoomId(Session.cs:60,63) are scalar FKs. - Reference navigations (
Session.cs:66-71):Event?andRoom?are[Navigation]-taggedget; set;properties (not read-only) that EF and the navigation populator (group-11) hydrate; the doc comment notesEventexists for query filtering (BR-132). Duration(Session.cs:76-78): a computedint?property (no column), derived fromStartsAt/EndsAt.- Child collections (
Session.cs:80-96): threeprivate readonly List<T>fields exposed asIReadOnlyCollection<T>via.AsReadOnly(), each tagged[Navigation(IsCollection = true)]. The backing lists are mutable only through aggregate methods, the consistency boundary in code. Create(Session.cs:150): validatesEnsureTitleIsValid+EnsureEndsAtIsAfterStartsAt(Session.cs:166-168), then constructs and emitsSessionChanged(Added)(Session.cs:192). Line 172 readstypeof(Session).IsIdValueGenerated;Sessionhas no[IdValueGenerated](IDs are Sessionize-assigned), so the factory assignsid!.Valuedirectly rather than leavingdefault(Session.cs:189).Update(Session.cs:216): same two-invariant check (Session.cs:232-234), mutates every field, emitsSessionChanged(Updated)(Session.cs:253).Delete(Session.cs:263): callsbase.Delete()(setsIsDeleted), then iterates the three child lists soft-deleting each non-deleted child (Session.cs:269-288), then emitsSessionChanged(Deleted)(Session.cs:290). Bails on the first child failure.- Child mutation methods (
AddSessionSpeaker/RemoveSessionSpeakeratSession.cs:304,335;AddSessionCategoryItem/RemoveSessionCategoryItemat:364,395;AddSessionQuestionAnswer/UpdateSessionQuestionAnswer/RemoveSessionQuestionAnswerat:425,449,472): each delegates to the child'sCreate/Delete/UpdateAnswer, mutates the private list, and emits the child-specific*Changedevent. TheRemove*/Update*helpers route throughGetChildOrNotFound<...>(the private wrappers atSession.cs:494-507) so a missing child id yields aNotFoundresult, not a null-ref. SetSession*methods (Session.cs:353,413,490):internalsetters used only by the navigation populator, they callSetItems(_list, items)(the base helper fromAuditableAggregateRootEntity) to replace in-memory collections during query-side population, bypassing domain logic; never used on the command side.
- Properties (
Why it's built this way: the aggregate boundary makes atomicity natural: one
SaveChangesAsynccommits the session and all its children together, and domain events on every method surface state changes to other modules via the outbox without tight coupling.[Rubric §29, Resilience]: cascade soft-delete prevents children being orphaned in a live-but-unreachable state.Where it's used: the central Conference entity: persisted by
EntityTypeConfigurationSQLServer<Session, SessionIdentifierType>(group-19), read throughIEntityQueryService<Session, SessionDTO, SessionIdentifierType>, and mutated by the Session command handlers (group-18).
SessionCategoryItem
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionCategoryItem.cs:13· Level 6 · class (sealed)
- What it is: a join entity linking a
Sessionto aCategoryItem, with database-generated identity ([IdValueGenerated],SessionCategoryItem.cs:12). It is a child of theSessionaggregate. - Depends on:
AuditableBaseEntity<TIdentifierType>(bound toSessionCategoryItemIdentifierType),IdValueGeneratedAttribute,EntityTypeExtensions,NavigationAttribute,Result,Session(back-reference). - Concept introduced, the join entity.
[Rubric §4, DDD]: rather than a raw EF join table, a many-to-many association is modeled as a domain entity, so it can carry its own identity, audit fields, and participate in domain events through the owning aggregate. The three Session children (SessionSpeaker, this, andSessionQuestionAnswer) all share this shape, they differ only in the FK(s) and any payload they carry. - Walkthrough:
CategoryItemId(SessionCategoryItem.cs:16,private set);Session?back-navigation (SessionCategoryItem.cs:20,[Navigation]);SessionId(SessionCategoryItem.cs:23, get-only, set by EF when the child is added toSession._sessionCategoryItems).Create(id?, categoryItemId)(SessionCategoryItem.cs:36-48): readstypeof(SessionCategoryItem).IsIdValueGenerated(truehere), soIdstaysdefaultfor the database to fill (SessionCategoryItem.cs:44). No domain validation (the association is structurally always valid). - Where it's used: managed exclusively through
Session.AddSessionCategoryItem/RemoveSessionCategoryItem.
SessionQuestionAnswer
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionQuestionAnswer.cs:13· Level 6 · class (sealed)
- What it is: a child entity of
Sessionstoring an answer to aQuestionfor that session. Database-generated identity ([IdValueGenerated],SessionQuestionAnswer.cs:12). - Depends on:
AuditableBaseEntity<TIdentifierType>(bound toSessionQuestionAnswerIdentifierType),IdValueGeneratedAttribute,EntityTypeExtensions,NavigationAttribute,Result,Session,SessionInvariants. - Concept: same join-entity pattern as
SessionCategoryItem; the difference is a validatedAnswerValuepayload.[Rubric §4, DDD]. - Walkthrough:
QuestionId(SessionQuestionAnswer.cs:16) andAnswerValue(SessionQuestionAnswer.cs:19, seeded tostring.Emptyby the EF ctor onSessionQuestionAnswer.cs:29);Session?navigation (SessionQuestionAnswer.cs:23); get-onlySessionId(SessionQuestionAnswer.cs:26).Create(id?, questionId, answerValue)(SessionQuestionAnswer.cs:46-64) validates non-empty/max-length viaSessionInvariants.EnsureAnswerValueIsValidbefore constructing.UpdateAnswer(answerValue)(SessionQuestionAnswer.cs:71-80) re-validates and mutates the field. - Where it's used: managed through
Session.AddSessionQuestionAnswer/UpdateSessionQuestionAnswer/RemoveSessionQuestionAnswer.
SessionSpeaker
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionSpeaker.cs:13· Level 6 · class (sealed)
- What it is: a join entity linking a
Sessionto aSpeaker, with database-generated identity ([IdValueGenerated],SessionSpeaker.cs:12). - Depends on:
AuditableBaseEntity<TIdentifierType>(bound toSessionSpeakerIdentifierType),IdValueGeneratedAttribute,EntityTypeExtensions,NavigationAttribute,Result,Session. - Concept: the same join-entity pattern as
SessionCategoryItem; the thinnest of the three (only aSpeakerIdpayload). - Walkthrough:
SpeakerId(SessionSpeaker.cs:16),Session?navigation (SessionSpeaker.cs:20), get-onlySessionId(SessionSpeaker.cs:23).Create(id?, speakerId)(SessionSpeaker.cs:36-48) just assigns identity, no domain validation here. Crucially, the duplicate-speaker invariant lives inSession.AddSessionSpeaker(Session.cs:308-315), not in this child: the aggregate root, not the child, owns cross-child uniqueness, the textbook placement of an invariant that spans the collection. - Where it's used: managed through
Session.AddSessionSpeaker/RemoveSessionSpeaker.
EventLiveInfo
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/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:4-8). 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(line 13) with three parameters,IsPublished(bool),LiveWindowStartUtc, andLiveWindowEndUtc(bothDateTime, the end being exclusive per the XML docs on lines 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, and across the process boundary by theEventLiveValidationServiceGrpcAdapter(group-20). The fail-open stubDisabledEventLiveValidationServicereturnsnew EventLiveInfo(true, DateTime.MinValue, DateTime.MaxValue). It is consumed by the EngagementLivePollcreate/open handlers, which enforce "draft requires a published event" and "opening a poll requires now to be inside the window".
QuestionModerationDefault
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/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 events/commands carry 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 barebool) leaves room for future moderation modes and reads self-documentingly at the call site. - Walkthrough: two explicitly-numbered members (lines 10, 13),
Pending = 0(the safe default: unset/zero means "hold for review") andApproved = 1. Explicit numbering keeps the wire meaning stable if 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.QuestionModerationDefaultand insideSessionLiveInfo(so the live layer learns the owning event's policy in the same call that fetches session facts). The stubDisabledEventLiveValidationServicereportsPending. Consumed by the EngagementSubmitQuestionhandler (group-23) when setting a new question's initial status.
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}/sessionize/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 the expected number of sessions, speakers, and categories landed. This is the read-back shape of a bulk write. - Walkthrough: eight
requiredproperties (lines 10-31), sixintcounts (CategoriesSynced,CategoryItemsSynced,RoomsSynced,QuestionsSynced,SpeakersSynced,SessionsSynced);SkippedSoftDeleted(line 28, BR-136: entities soft-deleted in the app are not restored when a sync re-encounters them); andWarnings(line 31,IReadOnlyList<string>, 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 their count doesn't match Sessionize; the count explains the gap. - Where it's used: the
RefreshFromSessionizeCommandhandler aggregates its per-strategySessionizeSyncResultvalues into this DTO (group-18); the Conference Events controller returns it as200 OK(group-20).
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 an organizer supplied. It is one of the three child-collection DTOs that
EventDTOcomposes. - Depends on:
IBaseDTO<TIdentifierType>(overEventQuestionAnswerIdentifierType); the FK fields use theEventIdentifierTypeandQuestionIdentifierTypealiases. - Concept introduced, the child-collection DTO and
required/initimmutability. The DTO shape and theIBaseDTOmarker were taught in group-12; here is the family of child DTOs an aggregate DTO composes.[Rubric §9, API & Contract Design](DTOs decoupled from domain entities, stable contracts): this record is the wire shape clients see, theEventQuestionAnswerjoin entity never crosses the boundary. Cross-aggregate references appear as scalar FKs (QuestionId), never nested objects, consistent with database-per-service (ADR-006) where the related aggregate may live in a different source. - Walkthrough: four
required initproperties (lines 12-21),Id(the strong id alias),EventId(FK to the parent event),QuestionId(FK to the question), andAnswerValue(the answer text). 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 FKs keeps the contract stable and portable across the service boundary, the same shape the sibling child DTOs use.
- Where it's used: nested in
EventDTO.EventQuestionAnswers; mapped from theEventQuestionAnswerentity by its Mapperly mapper (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>(overEventSpeakerIdentifierType); FK fields useEventIdentifierTypeandSpeakerIdentifierType. - Concept: same child-collection DTO shape introduced on
EventQuestionAnswerDTO, a flatrecord classwithrequired initmembers and scalar FKs.[Rubric §9, API & Contract Design]. - Walkthrough: three
required initproperties (lines 11-17),Id,EventId(FK to the parent event), andSpeakerId(FK to the speaker). Nothing else: this row exists only to associate anEventwith aSpeaker. - Where it's used: nested in
EventDTO.EventSpeakers; mapped from theEventSpeakerentity byEventSpeakerDTOMapper(group-18).
RoomDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/RoomDTO.cs:8· 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.
- Depends on:
IBaseDTO<TIdentifierType>(overRoomIdentifierType); the FK usesEventIdentifierType. - Concept: the same child-DTO shape as
EventQuestionAnswerDTO, extended with optional presentation fields.[Rubric §9, API & Contract Design](nullable optional fields let a room carry only the metadata that exists without forcing empty strings). - Walkthrough:
Id,Name, andEventIdarerequired(lines 11, 14, 32);Sort(line 17, display order) is a plainint; and four optional members,Capacity(int?),Floor,Location, andAccessibilityInfo(allstring?, lines 20-29), default to null when absent. NoteAccessibilityInfocarries the room's accessibility notes, part of the app's WCAG-aware venue data. - Where it's used: nested in
EventDTO.Rooms; mapped from theRoomentity by its Mapperly mapper (group-18); rendered by the Conference Room list/detail pages (group-21).
SessionLiveInfo
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/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 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 field); uses 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, and what is the default status for new questions (BR-233). Rather than force N separate cross-service calls, Conference bundles all of it into one record returned byGetSessionLiveInfoAsync.[Rubric §12, Performance & Scalability](one round-trip, not many) is the payoff of the bundling. - Walkthrough: a positional
sealed recordwith seven parameters (lines 17-24),EventId(the owning event),IsPublished,LiveWindowStartUtc,LiveWindowEndUtc(same live-window semantics asEventLiveInfo),SpeakerIds(IReadOnlyCollection<SpeakerIdentifierType>, the session's non-deleted assigned speakers),IsPlenumSession(bool), andQuestionModerationDefault. 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(which also enforces the eligibility rules BR-49/BR-91) and its gRPC adapter (group-20); the stubDisabledEventLiveValidationServicefails open with a default event id, an always-open window, no speakers, andPending. Consumed by the EngagementLivePollandSessionQuestionhandlers (group-23) for the speaker-moderation and question-visibility checks.
EventDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventDTO.cs:9· 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, composing its three child collections (rooms, speaker associations, question answers) plus the event's own scalar fields and concurrency token. - Depends on:
IBaseDTO<TIdentifierType>andIConcurrencyAware(both implemented, line 9); composesRoomDTO,EventSpeakerDTO,EventQuestionAnswerDTO; carriesQuestionModerationDefault. - Concept introduced, the aggregate DTO and 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, ADR-001 manual/Mapperly mapping):EventDTOis the Level-2 "composite" that bundles the Level-1 children.[Rubric §8, Data Architecture](optimistic concurrency): implementingIConcurrencyAwaremeans the DTO round-trips the EFRowVersiontoken (line 16) so an update form can detect a concurrent edit. The inlineSuppressMessageonRowVersion(line 15) documents that returning abyte[]is intentional, it is the raw rowversion token, and a second suppression onVenueMapUrl(line 40) records that a URL is stored as a plain string because it comes from external Sessionize input. - Walkthrough
- Identity and concurrency:
Id(required, line 12) and the nullableRowVersion(line 16). - Required core:
Name,StartDate,EndDate(DateOnly), andTimeZone(the IANA id used to compute the live window) arerequired(lines 19-31). - Optional scalars:
Description,SessionizeCode,VenueAddress,VenueMapUrl,WiFiInfo(allstring?), plusIsPublished(bool),QuestionModerationDefault(BR-233, line 50), and the two Sessionize-refresh audit fieldsLastSessionizeRefreshOn(DateTime?) /LastSessionizeRefreshBy(string?, lines 53-56). - Child collections (lines 59-65):
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.
- Identity and concurrency:
- 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 409 instead of a silent overwrite. - Where it's used: produced by
EventDTOMapper(group-18), returned by the Conference Events controller (group-20), and consumed throughout the Events UI (group-21). It is also the concrete event model thatCurrentEventDefaultsbindsCurrentEventSelectorto.
IEventLiveValidationService
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/IEventLiveValidationService.cs:11· Level 3 · interface
- What it is: the cross-module service contract the Engagement live layer calls to validate an event's or session's live-layer eligibility, returning
EventLiveInfo/SessionLiveInfowithout the caller ever referencing a Conference domain entity. - Depends on:
Result<T>(viaMMCA.Common.Shared.Abstractions, line 1);EventLiveInfo,SessionLiveInfo; theEventIdentifierType/SessionIdentifierTypealiases. - 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, the module that owns the data publishes the contract, and the interface 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 boundary pattern the module uses forISessionBookmarkValidationService. - Walkthrough: two methods, both returning
Task<Result<...>>with a trailingCancellationToken.GetEventLiveInfoAsync(line 21): 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), stated in the XML docs (lines 13-20).GetSessionLiveInfoAsync(line 32): 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 docs (lines 23-31).
- Why it's built this way: returning
Result<T>rather than throwing lets the consumer branch onNotFound/eligibility failures as ordinary control flow; keeping the contract in*.Shared(id-and-DTO only) is what makes Conference extractable without breaking Engagement. - Where it's used: implemented in-process by
EventLiveValidationService(Conference.Application, group-18), served over gRPC byEventLiveValidationGrpcServiceand consumed across the boundary viaEventLiveValidationServiceGrpcAdapter(registered byAddConferenceEventLiveValidationClient, whichReplaces whatever was in the container). Injected into the EngagementLivePollandSessionQuestionhandlers (group-23). When Conference is not loaded in a host, theDisabledEventLiveValidationServicestub stands in.
DisabledEventLiveValidationService
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/DisabledEventLiveValidationService.cs:22· Level 4 · class (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,EventLiveInfo,SessionLiveInfo,QuestionModerationDefault. - Concept introduced, the fail-open disabled-module stub (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/live-window checks (doc comment, lines 9-15). The 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 interfaces it exposes (e.g.DisabledSessionBookmarkValidationService, named in the doc, line 19). - Walkthrough: two one-line methods, each returning a completed
Taskwrapping a successResult.GetEventLiveInfoAsync(line 25):Result.Success(new EventLiveInfo(true, DateTime.MinValue, DateTime.MaxValue)), published, with a window spanning all of time.GetSessionLiveInfoAsync(line 34): a successSessionLiveInfowith adefault(unknown) event id, always-open window, no speakers ([]),IsPlenumSession = false, andQuestionModerationDefault.Pending. The remarks (lines 29-33) note the downstream effect: consumers skip the event-match check when the event id is default, and speaker-based rights resolve to organizers only.
- Why it's built this way: fail-open (rather than fail-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 in that configuration would be worse than skipping a check that a properly-wired gRPC client will perform. The choice is explicit and documented, not accidental.
- Where it's used: registered as the default
IEventLiveValidationServicein hosts where Conference is disabled (see the serviceProgram.csfiles, group-16/group-20), thenReplaced by the gRPC client in production wiring.
CurrentEventSelector
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:10· Level 6 · class (static)
- What it is: a static, generic helper that picks which published 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 itself.
- 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 §16, Maintainability]and[Rubric §1, SOLID](one algorithm, many callers, without a shared base type): several surfaces need "which event is current" (the two ADC home hosts, the EngagementLiveEventService, the role-scoped Conference list pages), and each has its own event DTO. 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, and an unknown zone id degrades to treating the local dates as UTC (a defensive, resilience-minded fallback) rather than throwing. - Walkthrough
SelectCurrentOrNext<TEvent>(events, startDate, endDate, timeZoneId, utcNow)(line 22): projects each event to its(StartUtc, EndUtc)window viaGetLiveWindowUtc(lines 30-36), then applies the preference order, live events (StartUtc <= utcNow < EndUtc) ordered by soonest to end (lines 38-42), else upcoming events (StartUtc > utcNow) ordered by soonest to start (lines 44-48), else the most recently ended event (OrderByDescending(EndUtc), line 52). Returnsnullwheneventsis empty. Ties resolve by input order (stable ordering).GetLiveWindowUtc(startDate, endDate, timeZoneId)(line 64): computesstartLocal = StartDateat 00:00 andendLocal = EndDate + 1 dayat 00:00, then converts both from the event's zone to UTC viaTimeZoneInfo.FindSystemTimeZoneById/ConvertTimeToUtc(lines 69-77). OnTimeZoneNotFoundExceptionit falls back toDateTime.SpecifyKind(..., Utc)(lines 79-84), treating the local dates as UTC to match existing consumers.
- 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 countdown, 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. - Where it's used: called by both ADCHome hosts (
MMCA.ADC.UIandMMCA.ADC.UI.Web.Client), the EngagementLiveEventService, and the Conference public/organizer list pages (session/speaker/room lists) to compute their default event filter; theEventDTO-shaped callers go through theCurrentEventDefaultsconvenience wrapper.
CurrentEventDefaults
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventDefaults.cs:8· Level 7 · class (static)
- What it is: a thin convenience wrapper over
CurrentEventSelectorspecialized to theEventDTOshape, so the many list pages and services that work withEventDTOdo not repeat the same accessor lambdas. - Depends on:
CurrentEventSelector,EventDTO. - Concept, the type-specialized wrapper (DRY over the generic helper).
[Rubric §16, Maintainability]: the genericCurrentEventSelector.SelectCurrentOrNext<TEvent>needs four accessor delegates on every call; since most callers passEventDTO, this wrapper binds those lambdas once (e => e.StartDate,e => e.EndDate,e => e.TimeZone) so call sites shrink toSelectCurrentOrNext(events, utcNow). - Walkthrough: one method,
SelectCurrentOrNext(IEnumerable<EventDTO> events, DateTime utcNow)(line 17), which forwards toCurrentEventSelector.SelectCurrentOrNextwith the threeEventDTOaccessors and returns the selectedEventDTO?(null when the input is empty). No other logic: all the ranking lives in the generic helper. - Why it's built this way: keeping the
EventDTOaccessors in one place means a rename of anEventDTOdate/time-zone property is a single edit here, not a change scattered across every list page. - Where it's used: the Conference list pages and any
EventDTO-based service computing a default event filter (group-21); 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 toSystem.Guid, a solution-wideglobal using); BCL only (DateTime,DateTimeOffset). This is an identity-less read projection, not a persisted-entity DTO, so it does not implementIBaseDTO<TIdentifierType>. - Concept introduced, the dual-clock read model.
[Rubric §9, API & Contract Design](assesses whether a contract gives 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 (NowNextDTO.cs:6-7) states the split rationale.[Rubric §12, Performance & Scalability]: precomputing both forms server-side keeps the mobile/widget client free of time-zone conversion. - Walkthrough: a positional
sealed recordwith seven parameters (NowNextDTO.cs:29-36):SessionId(the deep-link target),Title, a nullableRoomName(nullwhen 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 by a query handler from an already-valid aggregate, not a domain value object that must guard its own invariants. - Why it's built this way: a positional record gives free structural equality and immutability with
no boilerplate, which is all a read model needs. It is the row element of
NowNextDTOrather than a standalone contract, so it lives in the same file (ADR-042 Wave 8, cited in the doc commentNowNextDTO.cs:4). - Where it's used: nested as the
NowandNextlists onNowNextDTO; populated by the Conference now-next query handler and served on the public now-next endpoint behind the Android home-screen widget.
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); theEventIdentifierTypealias (System.Guid); 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, not reused across unrelated screens. - Walkthrough: a positional
sealed recordwith five parameters (NowNextDTO.cs:14-19):EventIdandEventNamename the featured event;IsLive(NowNextDTO.cs:17) istruewhen the event's live window contains the query instant;Nowis the sessions running right now (empty outside session hours) andNextis the next starting batch, all sharing the earliest future start (NowNextDTO.cs:12-13,18-19). Both batches areIReadOnlyList<NowNextSessionDTO>, so the shape is a fixed, ordered projection. - Why it's built this way: batching
Nextas a list (not a single session) lets several sessions that share the same next start time all surface together, which the doc comment calls out explicitly (NowNextDTO.cs:13). Local-plus-UTC times live on the row type, keeping this envelope thin. - Where it's used: returned by the Conference now-next query handler and its controller endpoint;
consumed by the Android widget and other glanceable surfaces described in the doc comment
(
NowNextDTO.cs:4-7).
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 (topic, format, level, and similar tag taxonomies).
- Depends on:
IBaseDTO<TIdentifierType>(the identified-DTO contract); theSessionCategoryItemIdentifierType,SessionIdentifierType, andCategoryItemIdentifierTypealiases. - 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 a 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 parent's child collection be replaced and diffed row by row. - Walkthrough: a
record classimplementingIBaseDTO<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 after. It shares its exact shape withSessionSpeakerDTO(see there for the family walkthrough). - Why it's built this way: a manually-declared record (mapped by hand or by a Mapperly mapper, ADR-001) keeps the wire contract explicit and decoupled from the EF entity.
- Where it's used: nested as
SessionCategoryItemsonSessionDTO(SessionDTO.cs:75); produced by the Conference session DTO mapper and navigation populators (ADR-002).
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 along with the speaker's answer value: the read-side row for a per-session questionnaire response (imported from Sessionize).
- Depends on:
IBaseDTO<TIdentifierType>; theSessionQuestionAnswerIdentifierType,SessionIdentifierType, andQuestionIdentifierTypealiases. - Concept: the join-row DTO with a payload column, a variant of the shape introduced by
SessionCategoryItemDTO. Unlike the pure two-FK joins, this one adds a data field.[Rubric §9, API & Contract Design](a join that also carries an attribute of the relationship). - Walkthrough: a
record classimplementingIBaseDTO<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) holding the answer text. This extra column is the only structural difference from the two pure join DTOs. - Why it's built this way: modeling the answer as a first-class join row (with 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.
- Where it's used: nested as
SessionQuestionAnswersonSessionDTO(SessionDTO.cs:72); produced by the session DTO mapper and navigation populators (ADR-002).
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>; theSessionSpeakerIdentifierType,SessionIdentifierType, andSpeakerIdentifierTypealiases. - Concept: identical in shape to
SessionCategoryItemDTO, the canonical two-FK join-row DTO. Both carry their ownIdplus the two association foreign keys and nothing else. - Walkthrough: a
record classimplementingIBaseDTO<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 pure join DTOs in this Sessions folder form a near-identical family, an{Id, parent FK, target FK}triple per join table:SessionSpeakerDTOandSessionCategoryItemDTOmatch member-for-member, whileSessionQuestionAnswerDTOadds one payload field. - 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
SessionDTOdiffable and lets the contract stay independent of the EF link entities (ADR-001). - Where it's used: nested as
SessionSpeakersonSessionDTO(SessionDTO.cs:69); produced by the session DTO mapper and navigation populators (ADR-002). This is the collection whose populator gap once dropped speakers from the list (vs. get-by-id) query, so it is the one to check when a session list shows no speakers.
SessionDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:9· Level 2 · record
- What it is: the full read-side contract for a conference session: its scalar fields (title, schedule, status flags, media URLs), its foreign keys to event and room, and its three child collections (speakers, question answers, category items).
- Depends on:
IBaseDTO<TIdentifierType>andIConcurrencyAware(the two DTO contracts it implements); its three child DTOsSessionSpeakerDTO,SessionQuestionAnswerDTO, andSessionCategoryItemDTO; theSessionIdentifierType,EventIdentifierType, andRoomIdentifierTypealiases. - Concept introduced, the concurrency-aware aggregate DTO.
[Rubric §9, API & Contract Design](assesses a read contract that mirrors an aggregate faithfully) and[Rubric §8, Data Architecture](optimistic concurrency carried to the client). BeyondIBaseDTO<TIdentifierType>'sId, this DTO also implementsIConcurrencyAware, so it round-trips the EFRowVersiontoken (SessionDTO.cs:16): a client reads it, echoes it back on update, and the update fails cleanly if the row changed underneath. The[SuppressMessage(... CA1819 ...)]on thebyte[]?(SessionDTO.cs:15) is a scoped, justified analyzer waiver, an array property is the shape EF's rowversion needs. - Walkthrough
- Identity and concurrency:
Id(SessionDTO.cs:12) and the nullableRowVersionbyte array (SessionDTO.cs:16) satisfy the two implemented DTO contracts. - Scalars (
SessionDTO.cs:19-60): arequired Title, an optionalDescription, an optionalStartsAt/EndsAtpair, a free-textStatus(Accepted/Declined/Waitlisted/Nominated), fourboolflags (IsInformed,IsConfirmed,IsServiceSession,IsPlenumSession), two URL strings (LiveUrl,RecordingUrl) kept asstringfor Sessionize compatibility (each with its own scopedCA1056waiver,SessionDTO.cs:46,50), plusAccessibilityInfo,ResourceLinks, and a nullableDurationin minutes. - Foreign keys: a
required EventId(SessionDTO.cs:63, every session belongs to an event) and an optionalRoomId(SessionDTO.cs:66, a session may be unscheduled to a room). - Child collections (
SessionDTO.cs:69-75):SessionSpeakers,SessionQuestionAnswers, andSessionCategoryItems, each anIReadOnlyCollection<...>defaulted to an empty collection literal[]so an unpopulated projection is nevernull.
- Identity and concurrency:
- Why it's built this way: defaulting each child collection to
[]means a list query that skips a populator returns an empty collection rather than a null reference; the trade-off is that a genuinely unpopulated collection is indistinguishable from an empty one, which is why the speakers-in-list populator gap was a silent bug and not a crash. Manual DTO shaping (ADR-001) keeps the contract explicit; navigation populators (ADR-002) fill the child collections on demand. - Where it's used: returned by the Conference session read endpoints (get-by-id and paged list) and
mapped from the
Sessionaggregate by the session DTO mapper; consumed by the Conference UI session pages and the session-detail views. - Caveats / not-in-source:
Statusis a free-textstring, not a closed enum, matching the imported Sessionize vocabulary; the DTO does not itself constrain the allowed values.
ISessionBookmarkValidationService
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/ISessionBookmarkValidationService.cs:10· 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(viaMMCA.Common.Shared.Abstractions,ISessionBookmarkValidationService.cs:1); theSessionIdentifierTypeandEventIdentifierTypealiases. - Concept introduced, the cross-module boundary interface.
[Rubric §7, Microservices Readiness](assesses whether module coupling flows through an interface that can be re-satisfied over a wire when the modules split into separate services). The doc comment (ISessionBookmarkValidationService.cs:5-9) states the arrangement: the interface is declared in the owning module'sSharedproject and implemented in Conference.Application, so Engagement depends only on this abstraction. When the two 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 the disabled stub (seeDisabledSessionBookmarkValidationService).[Rubric §6, CQRS & Event-Driven]: the two methods are query-shaped (they read and validate, they do not mutate). - Walkthrough: two async members.
ValidateSessionForBookmarkAsync(SessionIdentifierType, CancellationToken)(ISessionBookmarkValidationService.cs:19) returnsTask<Result>and, per its doc comment (ISessionBookmarkValidationService.cs:13-15), checks that the session exists, is not a service session (BR-91), and has an eligible status (BR-49).GetSessionIdsByEventAsync(EventIdentifierType, CancellationToken)(ISessionBookmarkValidationService.cs:28) returns the ids of all sessions in an event, used by Engagement for event-scoped bookmark filtering (BR-58). Both takeCancellationTokenas the final parameter, per the codebase convention.
- Why it's built this way: returning
Resultrather than throwing lets Engagement fold a validation failure into its own command result; keeping the interface inShared(notApplication) is what lets a disabled-stub or gRPC implementation be swapped in without Engagement seeing Conference's internals (ADR-007 gRPC extraction, ADR-008 service topology). - Where it's used: injected into Engagement's bookmark command handlers; implemented by
Conference.Application in-process, by a Conference gRPC adapter across services, and by
DisabledSessionBookmarkValidationServicewhen Conference is not loaded in the host.
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 (sealed)
- What it is: the no-op stub of
ISessionBookmarkValidationService, registered when the Conference module is not loaded in a host (for example the extracted Engagement service). It approves every validation and reports no sessions per event. - Depends on:
ISessionBookmarkValidationService(the interface it implements);Result(viaMMCA.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](the stub is the explicit, named "module disabled" contract rather than a silent null binding). The doc comment (DisabledSessionBookmarkValidationService.cs:5-28) spells out the trade-off:ValidateSessionForBookmarkAsyncreturns success so Engagement's bookmark handlers still complete, at the cost of skipping the BR-49/BR-91 eligibility checks; the real validation then happens at the Conference service when bookmark events flow through the broker, or later via a Conference gRPC adapter. It notes this mirrors the codebase-wide convention where each owning module's*.Sharedproject ships aDisabled*Servicestub (DisabledBookmarkCountServicein Engagement,DisabledAttendeeQueryServicein Identity). - Walkthrough: a
sealed classwith two one-line members.ValidateSessionForBookmarkAsync(...)(DisabledSessionBookmarkValidationService.cs:33-34) returnsTask.FromResult(Result.Success()), unconditionally approving.GetSessionIdsByEventAsync(...)(DisabledSessionBookmarkValidationService.cs:37-38) returnsTask.FromResult<IReadOnlyCollection<SessionIdentifierType>>([]), an empty collection, so an event-filtered bookmark query degrades to "no bookmarks for this event" instead of throwing.
- Why it's built this way: returning cached completed tasks (no allocation of async state machines)
keeps the stub cheap on a path that runs per request; the collection literal
[]gives a benign empty answer. Making degradation explicit and named (not a null service) is the governance point (ADR-008 service topology). - Where it's used: registered in the Engagement service's
Program.cs(via the module'sRegisterDisabledStubs()) to satisfyISessionBookmarkValidationServicewhen Conference is not in-process; never used when both modules run in one host.
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:60· 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:63-66),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.
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 48-54), 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) andScoredOn(line 42), so a score is auditable 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.ModelUsedandScoredOn(lines 39-42) capture provenance. The tail members are optional display context:Status(line 45, nullable),SpeakerLocalitiesandSessionCategories(lines 48-51, defaulted to[]so never null), andSessionLevel(line 54, 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 and timestamp keeps an AI-produced number honest and re-scorable.
- Where it's used: nested as the
AiScorescollection onSessionSelectionDashboardDTO; written byScoreEventSessionsHandler(which 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.
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
Userto aSpeaker(BR-209). It carries exactly one field, theUserIdto link. - Depends on: nothing first-party (uses the
UserIdentifierTypemodule alias →int); BCL only. - 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 validated, 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/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: the
SpeakersControllerlink action (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/SpeakersController.cs:46) binds it and maps it to theLinkUserToSpeakerCommand, whose handler publishes theSpeakerLinkedToUserintegration event.
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); 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). - Why it's built this way: carrying
QuestionTextandResponseCountalongside the average makes the record self-describing, a UI can show "4.6 (from 32 responses)" straight from the payload. - Where it's used: nested in
SessionFeedbackDTO.Ratings; built byGetSessionFeedbackHandler.
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 free-text 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. - Where it's used: nested in
SessionFeedbackDTO.TextResponses; built byGetSessionFeedbackHandler.
CategoryItemDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:8· 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); the aliasesCategoryItemIdentifierTypeandConferenceCategoryIdentifierType. - 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 with the same shape rule:- It implements
IBaseDTO<TIdentifierType>, the framework's minimal read-model contract, a singlerequired init Idof the entity's id alias (CategoryItemDTO.cs:11). That is the hook the generic query/serialization plumbing keys on. - It is populated by a Mapperly-generated mapper, not by hand. The companion
IEntityDTOMapper<CategoryItem, CategoryItemDTO, CategoryItemIdentifierType>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:12) is a[Mapper] partial class, the source generator writes the field-by-field copy at compile time (ADR-001), so there is no reflection cost and a shape mismatch is a build error.
- It implements
- Walkthrough: four members (
CategoryItemDTO.cs:11-20),Id(theIBaseDTOcontract), therequiredName, an optionalSort(int, display order), and therequiredCategoryIdFK back to the parent category.Sortis a plainint(defaults to 0) rather thanrequired, so an item without an explicit order sorts first. - 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→DTO copy allocation-light and drift-proof. - Where it's used: nested inside
ConferenceCategoryDTO.CategoryItemsandSpeakerCategoryItemDTO/session tagging; projected by anIEntityQueryService<CategoryItem, CategoryItemDTO, ...>and returned by the Categories controllers.
QuestionDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Questions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:9· 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); the aliasQuestionIdentifierType. - Concept introduced, the concurrency-aware DTO.
[Rubric §8, Data Architecture](assesses optimistic concurrency carried end to end). In addition to the entity-DTO pattern fromCategoryItemDTO, this DTO implementsIConcurrencyAware, which adds a nullablebyte[]? RowVersion(QuestionDTO.cs:16), the EF SQL Serverrowversiontoken round-tripped to the client so a later update can be rejected if the row changed underneath it. The[SuppressMessage(... CA1819 ...)]on the property (QuestionDTO.cs:15) is a scoped, justified suppression: exposing abyte[]from a property normally trips CA1819, but the token must round-trip as raw bytes. - Walkthrough: eight members (
QuestionDTO.cs:12-34),Id+RowVersion(the two contracts), therequiredQuestionText, then the optional descriptorsQuestionEntity("session"/"speaker"),QuestionType("text"/"select"),Sort,IsRequired, andQuestionSource("Sessionize" or "User", recording whether the question was imported or added in-app). - Why it's built this way: carrying
RowVersionon the DTO lets an edit form send back the exact token it read, 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); returned by the Questions controller and consumed by the answer-collection UI.
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, shown to the session's speaker (BR-210): the session identity plus two grouped result sets, numeric ratings and free-text responses.
- Depends on:
RatingQuestionSummary,TextQuestionResponses; the aliasesSessionIdentifierType,QuestionIdentifierType. - Concept, the composed query-projection report.
[Rubric §6, CQRS & Event-Driven](a read model purpose-built for one query rather than a mapped entity). 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, not 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 free-text question). Splitting ratings from text mirrors the two answer kinds a session can collect. - 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.
- Where it's used: returned by the
SpeakersControllerfeedback action (SpeakersController.cs:48) viaGetSessionFeedbackHandler; rendered by the Speaker dashboard UI.
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 locality) to aSpeaker. - Depends on:
IBaseDTO<TIdentifierType>; the aliasesSpeakerCategoryItemIdentifierType,SpeakerIdentifierType,CategoryItemIdentifierType. - Concept: the entity read DTO (see
CategoryItemDTO), here for a join entity: a flat record of foreign keys, no editable content of its own. - 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/remove only, so there is nothing to update optimistically. - Why it's built this way: modeling the speaker→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).
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 declared type). - Why it's built this way: keeping the answer as a flat
string AnswerValuelets one DTO carry any question type's answer (text, a selected option) without a type-specific shape. - Where it's used: nested in
SpeakerDTO.SpeakerQuestionAnswers; mapped by the speaker DTO mappers in the Conference Application layer.
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); the identifier-type aliasesConferenceCategoryIdentifierTypeandCategoryItemIdentifierType(moduleglobal usingaliases, see the primer). - 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. Two design choices are visible here and reused across this whole family:- It derives from
BaseDomainEventdirectly, not fromEntityChangedEvent<TIdentifierType>.EntityChangedEvent<T>models a single aggregate-root id; a child change needs two identifiers (the parent aggregate and the child) plus a descriptor, so it cannot fit that one-id shape. The aggregate-root lifecycle events later in this part (CategoryChangedand its siblings) do useEntityChangedEvent<T>. - It is a
sealed record classwith no behavior. Structural equality and the inheritedMessageId/DateOccurredcome fromBaseDomainEvent; the type exists purely soIDomainEventHandler<CategoryItemChanged>can be registered and dispatched independently of every other event type.
- 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
Category'sAddCategoryItem/UpdateCategoryItem/RemoveCategoryItem; collected on the aggregate and dispatched in-process byDomainEventDispatcherafterSaveChangesAsync. No dedicated handler subscribes to it today; it is available for future observers and audit.
ConferenceCategoryDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:9· 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 + its options) serializes in one response. - Walkthrough: six members (
ConferenceCategoryDTO.cs:12-28),Id+RowVersion(the contracts), therequiredTitle, an optionalSortandType("session"/"speaker"), and theCategoryItemscollection, anIReadOnlyCollection<CategoryItemDTO>initialized to[](ConferenceCategoryDTO.cs:28) 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(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs); returned by the Categories controller and consumed by the category-management UI.
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 child-change event an
Eventaggregate raises when one of itsEventQuestionAnswerchildren is added, updated, or removed. - Depends on:
BaseDomainEvent,DomainEntityState; aliasesEventIdentifierType,EventQuestionAnswerIdentifierType,QuestionIdentifierType. - Concept: the child-change domain event introduced by
CategoryItemChanged. The descriptor differs: instead of a display name it carriesQuestionId(line 16), the FK to theQuestionthat was answered, so a handler knows which question the answer belongs to. - Walkthrough:
State,EventId(parent),EventQuestionAnswerId(child),QuestionId(the answered question), lines 14-17. - Where it's used: raised by
Event's event-question-answer management methods; dispatched in-process. No dedicated handler subscribes today.
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 (linking the event to aSpeaker). - Depends on:
BaseDomainEvent,DomainEntityState; aliasesEventIdentifierType,EventSpeakerIdentifierType,SpeakerIdentifierType. - Concept: the child-change domain event (see
CategoryItemChanged), for a join entity. The doc comment (line 7) says "added or removed" with noUpdatedcase: a pure FK-pair join carries no editable content, so its lifecycle has only two meaningful transitions.Stateis stillDomainEntityState(it can holdAdded/Deleted). - Walkthrough:
State,EventId(parent),EventSpeakerId(the join row),SpeakerId(the linked speaker), lines 14-17. - Where it's used: raised by
Event'sAddEventSpeaker/RemoveEventSpeaker; dispatched in-process. No dedicated handler subscribes today.
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
CategoryItemChanged). This is the one member of the Level-2 family with a dedicated handler, so it is a concrete sighting of theIDomainEventHandler<T>boundary:RoomChangedHandlerin the Conference Application layer subscribes to it and reacts on theStatetransition.[Rubric §6, CQRS & Event-Driven](typed event to typed handler, dispatched by Scrutor-discovered registration). - Walkthrough:
State,EventId(parent),RoomId(child),RoomName(display label), lines 14-17. - Where it's used: raised by
Event'sAddRoom/UpdateRoom/RemoveRoom; dispatched in-process and consumed byRoomChangedHandler.
SpeakerDTO
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:9· 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,UserIdentifierType. - Concept, the cross-context read DTO.
[Rubric §7, Microservices Readiness]and[Rubric §8, Data Architecture]. LikeConferenceCategoryDTOit is a concurrency-aware aggregate DTO with nested child collections, but it also surfacesLinkedUserId(SpeakerDTO.cs:58), a nullable, cross-database FK to an IdentityUser. That id is a scalar, not an EF navigation, because the User and Speaker live in separate databases (ADR-006); the link is reconciled by events (seeSpeakerChangedandSpeakerLinkedToUser), never a cross-database join. Three URL-ish fields (LinkedInUrl/GitHubUrl/WebsiteUrl) carry a scoped[SuppressMessage(... CA1056 ...)](SpeakerDTO.cs:46,50,54) because they are stored as strings from the Sessionize import. - Walkthrough: seventeen members (
SpeakerDTO.cs:12-64),Id+RowVersion(contracts); therequiredname fieldsFirstName/LastName/FullName(FullNameis denormalized so the client shows a display name without concatenating); optional profile fields (Email,Bio,TagLine,ProfilePicture, theIsTopSpeakerflag, the three social URLs); the nullableLinkedUserId; and the two child collectionsSpeakerCategoryItemsandSpeakerQuestionAnswers, bothIReadOnlyCollection<...>defaulted to[](SpeakerDTO.cs:61,64). - Why it's built this way: denormalizing
FullNameand defaulting both collections keeps the speaker UI a thin renderer; exposingLinkedUserIdas a bare nullable id (not a nested user object) is exactly the database-per-service posture, the Conference read model knows the id of the linked user but never reaches across the boundary to fetch it. - Where it's used: projected by an
IEntityQueryService<Speaker, SpeakerDTO, SpeakerIdentifierType>injected into theSpeakersController(SpeakersController.cs:42); mapped bySpeakerDTOMapper(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs); rendered by the Speaker dashboard UI.
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); aliasConferenceCategoryIdentifierType. - Concept introduced, the aggregate-root lifecycle event.
[Rubric §6, CQRS & Event-Driven]and[Rubric §16, Maintainability](a single 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, and each concrete record passes its own id up to that base (line 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, two property names, so handlers written againstEntityChangedEvent<T>and handlers written against the concrete type both work. - Walkthrough: three positional members (lines 13-15),
State,CategoryId, andTitle;StateandCategoryIdare forwarded to the base constructor (line 16),Titleis the record's own added property. - Why it's built this way: one lifecycle event per aggregate keeps the event surface small (ADR-010
contract-versioning applies to the shared base), and
Statelets a handler branch on the transition rather than subscribing to three separate types. - Where it's used: raised from
Category'sCreate/Update/Delete; dispatched in-process byDomainEventDispatcher.
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-lifecycle event an
Eventraises when it is created, updated, or deleted (including on publish/unpublish, which flip state and emitEventChanged(Updated)). Carries the event id and name. - Depends on:
EntityChangedEvent<TIdentifierType>,DomainEntityState; aliasEventIdentifierType. - Concept: the aggregate-root lifecycle event introduced by
CategoryChanged; structurally identical, carryingName(line 14) as its descriptor. - Walkthrough:
State,EventId,Name(lines 13-15);State+EventIdforwarded to the base (line 16). - Where it's used: raised from
Event'sCreate/Update/Publish/Unpublish/Delete; dispatched in-process.
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. It is the narrowest kind of Conference event: a notice that a child collection of an aggregate changed, not that the aggregate root itself changed. - Depends on:
BaseDomainEvent(base record) and theDomainEntityStateenum; the module id aliasesSessionIdentifierType,SessionCategoryItemIdentifierType,CategoryItemIdentifierType(BCL scalars behind the alias). No NuGet dependency. - Concept introduced, the child-collection domain event.
[Rubric §6, CQRS & Event-Driven](assesses whether state changes are announced as first-class events rather than leaked as side effects) and[Rubric §4, Domain-Driven Design](assesses whether the aggregate root is the sole author of change inside its consistency boundary). The aggregate-root-level events later in this part (SessionChanged,SpeakerChanged) derive fromEntityChangedEvent<TIdentifierType>; this event and its four siblings instead derive directly fromBaseDomainEventbecause a join/child row has no independent lifecycle event of its own to reuse. It carries three ids so a handler can react without reloading: the parentSession, the join entity, and theCategoryItemthat was linked. How a raised event reaches the outbox and in-process handlers is taught once in Group 04; this part only produces them. - Walkthrough: a positional
sealed record classwith four members (SessionCategoryItemChanged.cs:13-17):State(theDomainEntityState,AddedorDeletedfor a join row),SessionId,SessionCategoryItemId, andCategoryItemId. Being a record, structural equality and immutability come for free; the primary-constructor parameters are the only state. - Why it's built this way: publishing the ids rather than the entity keeps the event a flat, serializable fact (it must survive an outbox round-trip, ADR-003) and keeps a handler from touching the aggregate's internals. Raising a distinct event per join type (rather than one generic "session updated") lets read-side cache invalidation and projections target exactly what moved.
- Where it's used: raised inside
Session's category-item add/remove methods; consumed by in-processIDomainEventHandlerimplementations and captured by the outbox inSaveChangesAsync.
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, but for the answer child rather than the category join. - Depends on:
BaseDomainEvent,DomainEntityState; aliasesSessionIdentifierType,SessionQuestionAnswerIdentifierType,QuestionIdentifierType. - Concept: the child-collection domain event introduced by
SessionCategoryItemChanged.[Rubric §6, CQRS & Event-Driven]. The one behavioral difference is theUpdatedstate: an answer's text can change in place (a join row cannot), so this event'sStatespansAdded,Updated, andDeleted. - Walkthrough:
sealed record classwithState,SessionId,SessionQuestionAnswerId,QuestionId(SessionQuestionAnswerChanged.cs:13-17). - Where it's used: raised by
Session's question-answer methods; captured by the outbox.
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 (session-to-speaker association) is added or removed. - Depends on:
BaseDomainEvent,DomainEntityState; aliasesSessionIdentifierType,SessionSpeakerIdentifierType,SpeakerIdentifierType. - Concept: the child-collection domain event (
SessionCategoryItemChanged).[Rubric §6, CQRS & Event-Driven]. A join row, soStateisAddedorDeletedonly. - Walkthrough:
sealed record classwithState,SessionId,SessionSpeakerId,SpeakerId(SessionSpeakerChanged.cs:13-17). - Where it's used: raised by
Session's speaker-association methods; captured by the outbox.
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 a speaker's topic or locality tags). - Depends on:
BaseDomainEvent,DomainEntityState; aliasesSpeakerIdentifierType,SpeakerCategoryItemIdentifierType,CategoryItemIdentifierType. - Concept: the child-collection domain event (
SessionCategoryItemChanged).[Rubric §6, CQRS & Event-Driven]. Structurally identical to the session variant with the parent id swapped from session to speaker. - Walkthrough:
sealed record classwithState,SpeakerId,SpeakerCategoryItemId,CategoryItemId(SpeakerCategoryItemChanged.cs:13-17). - Where it's used: raised by
Speaker's category-item methods; captured by the outbox.
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 from a speaker. - Depends on:
BaseDomainEvent,DomainEntityState; aliasesSpeakerIdentifierType,SpeakerQuestionAnswerIdentifierType,QuestionIdentifierType. - Concept: the child-collection domain event (
SessionCategoryItemChanged).[Rubric §6, CQRS & Event-Driven]. As with the session answer, the answer text is mutable, soStatespansAdded,Updated, andDeleted. - Walkthrough:
sealed record classwithState,SpeakerId,SpeakerQuestionAnswerId,QuestionId(SpeakerQuestionAnswerChanged.cs:13-17). - Where it's used: raised by
Speaker's question-answer methods; captured by the outbox.
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: raised when a question (the reusable custom-form question definition) is created, updated, or deleted. - Depends on:
EntityChangedEvent<TIdentifierType>(base),DomainEntityState; aliasQuestionIdentifierType. - Concept introduced, the aggregate-root change event via
EntityChangedEvent<T>.[Rubric §6, CQRS & Event-Driven]and[Rubric §4, Domain-Driven Design]. Where the Level-2 events above derive straight fromBaseDomainEvent, the root-level events derive from the sharedEntityChangedEvent<TIdentifierType>base (taught in Group 04), which standardizes the(State, Id)pair every "an entity's lifecycle moved" event needs. The record adds only what a subscriber needs beyond that pair. - Walkthrough:
sealed record class QuestionChanged(DomainEntityState State, QuestionIdentifierType QuestionId, string QuestionText)passing(State, QuestionId)up to theEntityChangedEvent<QuestionIdentifierType>primary constructor (QuestionChanged.cs:12-16). The extraQuestionTextrides along so a projection or cache-invalidation handler can act without a reload. - Why it's built this way: reusing
EntityChangedEvent<T>keeps theState/Idcontract uniform across every aggregate's change event, so generic subscribers (audit, cache) can treat them polymorphically. - Where it's used: raised by the
Questionfactory/update/delete methods; captured by the outbox and dispatched in-process.
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 session create, update, or delete. - Depends on:
EntityChangedEvent<TIdentifierType>,DomainEntityState; aliasesSessionIdentifierType,EventIdentifierType. - Concept: the aggregate-root change event introduced by
QuestionChanged.[Rubric §6, CQRS & Event-Driven]. It carries the parentEventIdin addition toTitle, so a subscriber knows which event's schedule was touched (useful for scoped cache invalidation of that event's session list). - 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's lifecycle methods; captured by the outbox.
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 speaker create, update, or delete. Its distinctive feature is a nullablePreviousLinkedUserIdthat captures the speaker-to-user link as it stood before a delete. - Depends on:
EntityChangedEvent<TIdentifierType>,DomainEntityState; aliasesSpeakerIdentifierType,UserIdentifierType. - Concept: the aggregate-root change event (
QuestionChanged), plus carrying pre-mutation state on the event for cross-context cleanup.[Rubric §6, CQRS & Event-Driven]and[Rubric §7, Microservices Readiness](a delete in Conference must trigger unlink cleanup on the Identity side, and that must not depend on reading a field the delete has already cleared). Per the XML doc (SpeakerChanged.cs:12-15),PreviousLinkedUserIdis populated only onDomainEntityState.Deletedso the handler can perform BR-70 cross-context cleanup after the entity's own link field has been nulled. - 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 defaultnullmeans non-delete transitions omit it. - Why it's built this way: an event is an immutable record of what already happened; snapshotting the prior link onto the event avoids a lost-update race where the cleanup handler would otherwise read a cleared field. It also decouples the delete transaction from the downstream unlink, which crosses a module (and eventually a service) boundary.
- Where it's used: raised by
Speaker's lifecycle methods; a Conference-side handler translates the delete into theSpeakerUnlinkedFromUserintegration event.
SpeakerLinkedToUser
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers.IntegrationEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerLinkedToUser.cs:20· Level 3 · record (sealed)
- What it is: the cross-module integration event Conference publishes when it binds a
Speakerto an IdentityUser(either via the manual link command or via automatic email-match linking triggered byUserRegistered). Identity subscribes to it and setsUser.LinkedSpeakerId, so the next JWT refresh carries thespeaker_idclaim (BR-209). - Depends on:
BaseIntegrationEvent(base); aliasesUserIdentifierType,SpeakerIdentifierType. - Concept introduced, the integration event (vs the domain event).
[Rubric §7, Microservices Readiness](assesses whether cross-module coupling runs through published contracts a peer can consume without a code reference back) and[Rubric §9, API & Contract Design](the async message is a public contract). Two things distinguish it from every event above. First, it derives fromBaseIntegrationEvent, notBaseDomainEvent: a domain event stays inside the producing module, an integration event is meant to cross a module/service boundary over the broker (RabbitMQ locally, Azure Service Bus in production) 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. Per the XML doc (SpeakerLinkedToUser.cs:11-16), it replaced a former direct in-process service call (IUserSpeakerLinkService.LinkSpeakerAsync), making the bidirectional User-Speaker link eventually consistent across boundaries. - Walkthrough:
sealed record class SpeakerLinkedToUser(UserIdentifierType UserId, SpeakerIdentifierType SpeakerId) : BaseIntegrationEvent(SpeakerLinkedToUser.cs:20-23). Just the two ids of the link; the receiver needs nothing more to setLinkedSpeakerId. - Why it's built this way: modeling the link as a published fact rather than a synchronous call is the outbox/eventual-consistency story (ADR-003); it lets Identity and Conference run as separate services with no shared database and no cross-database FK.
- Where it's used: published by the Conference link-command handler; consumed by Identity's
SpeakerLinkedToUserHandler.
SpeakerUnlinkedFromUser
MMCA.ADC.Conference.Shared ·
MMCA.ADC.Conference.Shared.Speakers.IntegrationEvents·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerUnlinkedFromUser.cs:17· Level 3 · record (sealed)
- What it is: the inverse of
SpeakerLinkedToUser: the integration event Conference publishes when a speaker is unlinked from a user (via the unlink command, or as cascade cleanup when a speaker is soft-deleted). Identity subscribes and clearsUser.LinkedSpeakerId. - Depends on:
BaseIntegrationEvent; aliasesUserIdentifierType,SpeakerIdentifierType. - Concept: the integration event introduced by
SpeakerLinkedToUser.[Rubric §7, Microservices Readiness]. Same contract shape; theSpeakerIdhere is carried mainly for audit/log correlation (the id being cleared isUserId), per the XML doc (SpeakerUnlinkedFromUser.cs:15-16). - Walkthrough:
sealed record class SpeakerUnlinkedFromUser(UserIdentifierType UserId, SpeakerIdentifierType SpeakerId) : BaseIntegrationEvent(SpeakerUnlinkedFromUser.cs:17-20). - Why it's built this way: it closes the loop on the eventually-consistent link (replacing the former
IUserSpeakerLinkService.ClearLinkedSpeakerAsyncdirect call,SpeakerUnlinkedFromUser.cs:10-13), and it is the downstream half of aSpeakerChangeddelete (which is why that event snapshotsPreviousLinkedUserId). - Where it's used: published by the Conference unlink-command handler and by the speaker-delete cleanup path; consumed by Identity's
SpeakerUnlinkedFromUserHandler.
EventInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:10· Level 4 · 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 both the domain factories and the EF configuration reuse, so a business rule is stated once. - Depends on:
CommonInvariants(the reusable lower layer it delegates to),Result/Error; BCLTimeZoneInfoandDateOnly. AliasRoomIdentifierType. - Concept introduced, the module invariants class.
[Rubric §4, Domain-Driven Design](invariants live in the domain, expressed as reusable rules) and[Rubric §8, Data Architecture](theMaxLengthconstants are the single source of truth shared by EF column config and validation, keeping schema and rule in sync). This is the same static-invariants idiom taught for value objects in Group 02, applied to an aggregate: eachEnsure...returns aResult, and callers combine several viaResult.Combine. - Walkthrough, in teaching order:
- Length constants (
EventInvariants.cs:13-46):NameMaxLength(500),DescriptionMaxLength(4000),TimeZoneMaxLength(100),SessionizeCodeMaxLength(100),VenueAddressMaxLength(500),VenueMapUrlMaxLength(2000),WiFiInfoMaxLength(500), the fourRoom...limits (RoomNameMaxLength255,RoomFloorMaxLength100,RoomLocationMaxLength255,RoomAccessibilityInfoMaxLength500), andAnswerValueMaxLength(4000). Referenced by both domain checks and EF config. - Reserved id range (
EventInvariants.cs:53-56):RoomManualIdRangeStart(999_999_000) andRoomManualIdRangeEnd(999_999_999). Rooms carry app-assigned ids where the int PK is the Sessionize id; organizer-created rooms draw from this reserved high range so they never collide with a real Sessionize id (mirrorsSessionInvariants.ManualIdRangeStart). EnsureNameIsValid(:58): combines a not-empty and a max-length check viaCommonInvariants.EnsureTimeZoneIsValid(:69): not-empty, then max-length, thenTimeZoneInfo.FindSystemTimeZoneByIdinside a try/catch that mapsTimeZoneNotFoundExceptionto anEvent.TimeZone.Invalidinvariant error (BR-87, IANA identifier check).EnsureDateRangeIsValid(:107): fails withEvent.DateRange.InvalidwhenendDate < startDate.EnsureRoomCapacityIsValid(:122): rejects a non-positive capacity when one is supplied (capacity is <= 0, BR-93).EnsureRoomNameIsValid(:131) andEnsureAnswerValueIsValid(:136): not-empty plus max-length pairs for the two children.EnsureEventIsPublished(:147): guards actions that require a published event (BR-108).
- Length constants (
- Why it's built this way: keeping the length limits as
const/static readonlyfields on the invariants class, and having EF configuration read the same constants, prevents the classic drift where a validator allows a value the column truncates. ReturningResult(never throwing) keeps validation composable at the factory. - Where it's used:
Event,Room, andEventQuestionAnswerfactories/updaters call these; the Events EF configuration reads the length constants.
Event
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:17· Level 5 · 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). - Depends on:
AuditableAggregateRootEntity<TIdentifierType>(base),EventInvariants, theResult/Errortypes,DomainEntityState, theIdValueGeneratedAttribute, and theEventChanged/RoomChanged/EventSpeakerChanged/EventQuestionAnswerChangeddomain events. UsesQuestionModerationDefaultandTimeProviderfor clock-free refresh stamping. AliasesEventIdentifierType,RoomIdentifierType,EventSpeakerIdentifierType,EventQuestionAnswerIdentifierType,SpeakerIdentifierType,QuestionIdentifierType,UserIdentifierType. - Concept introduced, the aggregate root as the consistency boundary.
[Rubric §4, Domain-Driven Design](assesses whether invariants are enforced inside the boundary and children are mutated only through the root) and[Rubric §1, SOLID]. This is the fullest expression in this part of the aggregate pattern taught in Group 02: 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. - Walkthrough, in teaching order:
[IdValueGenerated]on the class (Event.cs:16): marks the id as database-generated; the factory reads this at runtime viatypeof(Event).IsIdValueGenerated.- Scalar state (
Event.cs:20-60):Name,Description?,StartDate/EndDate(DateOnly),TimeZone,SessionizeCode?,VenueAddress?,VenueMapUrl?,WiFiInfo?,IsPublished,QuestionModerationDefault(the BR-233 default status a newly submitted live-layer question gets), and the nullableLastSessionizeRefreshOn/LastSessionizeRefreshByrefresh-audit pair. All have private setters. - Child collections (
Event.cs:62-78): privateList<Room>/List<EventSpeaker>/List<EventQuestionAnswer>exposed asIReadOnlyCollection<...>marked[Navigation(IsCollection = true)]for the populator (ADR-002). - Constructors (
Event.cs:81-107): a parameterless EF constructor that seeds non-null strings, plus a private ctor used by the factory. Create(Event.cs:125): combinesEnsureNameIsValid+EnsureTimeZoneIsValid+EnsureDateRangeIsValid; on success builds the instance, assignsIdasdefaultwhen id-value-generated (else the passed id), setsQuestionModerationDefault, and raisesEventChanged(Added). ReturnsResult<Event>.Update(Event.cs:182): re-validates the same three invariants, writes the scalars, raisesEventChanged(Updated).Publish/Unpublish(Event.cs:219,:239): flipIsPublished, refusing a no-op transition with an invariant error, and raiseEventChanged(Updated).RecordSessionizeRefresh(Event.cs:263): stampsLastSessionizeRefreshOn/Byfrom a caller-supplied UTC instant (drawn from an injectedTimeProvider) so the domain never reads an ambient clock.Delete(Event.cs:275): overrides the base soft-delete, then cascade soft-deletes every non-deleted room, event-speaker, and answer (BR-72), and raisesEventChanged(Deleted). Session cascade is deliberately handled at the application layer (BR-127).- Child management (
Event.cs:321-547):AddRoom/UpdateRoom/RemoveRoom,AddEventSpeaker/RemoveEventSpeaker,AddEventQuestionAnswer/UpdateEventQuestionAnswer/RemoveEventQuestionAnswer. Each guards duplicates (for example a room name or a repeated speaker), delegates creation to the child factory, mutates the private list, and raises the matching child event.SetRooms/SetEventSpeakers/SetEventQuestionAnswers(internal) let the populator replace a collection via the baseSetItemshelper. - Private helpers (
Event.cs:550-563):GetRoomOrNotFound/GetEventSpeakerOrNotFound/GetEventQuestionAnswerOrNotFoundwrap the baseGetChildOrNotFound<T>so a missing child returns anError.NotFoundrather than a null.
- 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 and what gives the outbox a single, ordered stream of change events. Passing the clock in rather than reading
DateTime.UtcNowkeeps the domain deterministic and testable ([Rubric §14, Testability]). - Where it's used: loaded and mutated by the Conference application-layer command handlers (Group 18); persisted via the Events EF configuration; projected to DTOs for the read endpoints.
EventQuestionAnswer
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:13· Level 5 · class (sealed, child entity)
- What it is: a child entity of
Eventstoring the event's answer to one custom-formQuestion. Database-generated id. - Depends on:
AuditableBaseEntity<TIdentifierType>(base, a child not an aggregate root),EventInvariants,Result,IdValueGeneratedAttribute. AliasesEventQuestionAnswerIdentifierType,QuestionIdentifierType,EventIdentifierType. - Concept introduced, the child entity (vs the aggregate root).
[Rubric §4, Domain-Driven Design]. A child derives fromAuditableBaseEntity, so it has identity, soft-delete, and audit fields but no domain-event list: children never raise events themselves, the owning root (Event) does. It carries the FKEventIdand a back-navigationEvent?for EF. - Walkthrough:
[IdValueGenerated](:12);QuestionId(FK) andAnswerValuewith private setters (:16-19);[Navigation] Event?andEventId(:22-26); an EF ctor and a private ctor (:29-37);Create(:46) which validatesEnsureAnswerValueIsValidand assigns the id per the id-generation flag;UpdateAnswer(:71) which re-validates then setsAnswerValue. Neither method raises an event, the root wraps them and raisesEventQuestionAnswerChanged. - 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 events flow through the root's ordered stream.
- Where it's used: created and mutated only through
Event'sAddEventQuestionAnswer/UpdateEventQuestionAnswer/RemoveEventQuestionAnswer.
EventSpeaker
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:13· Level 5 · class (sealed, join entity)
- What it is: the join entity linking an
Eventto aSpeaker(which speakers appear at which event). Database-generated id. - Depends on:
AuditableBaseEntity<TIdentifierType>,Result,IdValueGeneratedAttribute. AliasesEventSpeakerIdentifierType,SpeakerIdentifierType,EventIdentifierType. - Concept: the child/join entity introduced by
EventQuestionAnswer.[Rubric §4, Domain-Driven Design]. This is the thinnest child in the family: it holds only theSpeakerIdFK plus the standard back-navigation andEventId, so itsCreate(:36) does no validation beyond assigning the id per the id-generation flag and returnsResult<EventSpeaker>. There is noUpdate: a join either exists or it does not. - Walkthrough:
[IdValueGenerated](:12);SpeakerId(:16);[Navigation] Event?andEventId(:19-23); EF ctor and private ctor (:26-28);Create(:36-48). - Why it's built this way: modeling the many-to-many as an explicit join entity (rather than an EF-implicit link table) gives the association its own id, soft-delete, and audit trail, and lets
EventraiseEventSpeakerChangedwhen it is added or removed. - Where it's used: created/removed only through
Event'sAddEventSpeaker/RemoveEventSpeaker; the duplicate-speaker guard lives in the root.
Room
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:12· Level 5 · 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 (note the absence of[IdValueGenerated]). - Depends on:
AuditableBaseEntity<TIdentifierType>,EventInvariants,Result. AliasesRoomIdentifierType,EventIdentifierType. - Concept: the child entity (
EventQuestionAnswer), with an externally-assigned id.[Rubric §8, Data Architecture]. BecauseRoomis not marked[IdValueGenerated],Create(:69) always assigns the supplied id (itstypeof(Room).IsIdValueGeneratedis false), which is how a Sessionize room id becomes the PK directly. Organizer-created rooms draw from the reserved high range (EventInvariantsRoomManualIdRangeStart) so app-assigned ids never collide with imported ones. - Walkthrough: scalars
Name,Sort,Capacity?,Floor?,Location?,AccessibilityInfo?(:15-30);[Navigation] Event?andEventId(:33-37); EF ctor and private ctor (:40-56);Create(:69) validatingEnsureRoomNameIsValid+EnsureRoomCapacityIsValid;Update(:110) re-validating the same pair and writing the scalars. 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 (a re-import updates in place rather than creating duplicates), and the reserved manual range lets organizers add rooms without an id clash.
- Where it's used: created/updated/removed through
Event'sAddRoom/UpdateRoom/RemoveRoom(which raiseRoomChanged); referenced bySessionscheduling.
QuestionInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Questions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:10· Level 4 · class (static)
- What it is: 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(Level 3),Error(Level 1),Result(Level 2); BCLMailAddress,int.TryParse. - Concept: the same Invariants-class pattern used across the Conference aggregates (see
EventInvariants), but notably richer.[Rubric §4, DDD](closed value sets and answer rules expressed as domain logic, not as API/UI validation). The permitted values are held as data, not as longswitches:ValidQuestionEntities,ValidQuestionTypes,ValidQuestionSourcesareprivate static readonly string[](lines 28-34) checked withStringComparer.OrdinalIgnoreCase. - Walkthrough
- Length constants (lines 13-25) and the user-created id-range constants
ManualIdRangeStart/End(lines 37-40, distinguishing Sessionize ids from user-created ones). EnsureQuestionTextIsValid(line 48): explicitIsNullOrWhiteSpaceguard then max-length viaCommonInvariants.EnsureStringMaxLength.EnsureQuestionEntityIsValid/EnsureQuestionTypeIsValid/EnsureQuestionSourceIsValid(lines 68, 83, 98): membership tests against the closed arrays.EnsureAnswerValueMatchesQuestionType(line 115): aswitchonquestionTypedispatching to three private validators, what counts as a valid answer depends on the question's type:ValidateRatingAnswer(line 128):int.TryParsewithNumberStyles.Integer+CultureInfo.InvariantCulture, must be1..5; otherwiseError.Validation. (Culture-invariant parsing, see the primer §27 note.)ValidateTextAnswer(line 142): length not exceedingTextAnswerMaxLength(2000).ValidateEmailAnswer(line 156): constructs aSystem.Net.Mail.MailAddressand treats aFormatExceptionas invalid, the BCL is the format authority.- An unknown type returns
Error.Invariant("Question.QuestionType.Unknown").
- Length constants (lines 13-25) and the user-created id-range 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 the database or a handler; expressing the allowed sets as arrays keeps adding a new question type a one-line data change.
- Where it's used: called from
Question'sCreate/Update; the answer-matching rule is used by the answer-recording handlers (Application tier); length constants feed EF config.
SpeakerInvariants
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:10· Level 4 · class (static)
- What it is: domain rules for the
Speakeraggregate: first-name, last-name, and answer-value non-empty/length constraints, plus the length constants for every profile field. - Depends on:
CommonInvariants(Level 3),Result(Level 2). - Concept: cross-reference
EventInvariants. This is the simplest sibling: no cross-field or type-dispatch checks. The bulk of the class is length constants for the rich speaker profile,FirstNameMaxLength(200),EmailMaxLength(255),TagLineMaxLength(500), and the four social/URL fields at 2000 (ProfilePictureMaxLength,LinkedInUrlMaxLength,GitHubUrlMaxLength,WebsiteUrlMaxLength), allconst int(lines 12-40) and read by the Speaker EF configuration so column widths stay in sync. - Walkthrough: three
EnsureXxxmethods (lines 42-55), each aResult.CombineofCommonInvariants.EnsureStringIsNotEmpty+EnsureStringMaxLength(EnsureFirstNameIsValid/EnsureLastNameIsValid/EnsureAnswerValueIsValid, the last usingAnswerValueMaxLengthof 4000). Note that email is not validated here, theSpeakerfactory parses it through theEmailvalue object instead. - Where it's used:
Speaker'sCreate/UpdateandSpeakerQuestionAnswer'sCreate/UpdateAnswer; length constants feed EF config.
Category
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:16· Level 5 · class (sealed), SCC withCategoryInvariants,CategoryItem
What it is: the aggregate root for conference categories (e.g. "Level", "Track", "Session Format"). Each category owns a collection of
CategoryItemchildren representing the selectable options within it.Depends on,
AuditableAggregateRootEntity<TIdentifierType>(Level 4),CategoryInvariants(SCC),CategoryItem(SCC),Result(Level 2),DomainEntityState(Level 0),IdValueGeneratedAttribute(Level 0),NavigationAttribute(Level 0); domain eventsCategoryChanged,CategoryItemChanged(Level 2-3).Concept introduced, the aggregate root.
[Rubric §4, Domain-Driven Design](assesses aggregates as the central consistency boundary, all mutations route through the root). An aggregate root is the only member of its cluster a repository hands out; callers never hold a bareCategoryItem. Two consequences visible here:- All child mutations route through the parent.
AddCategoryItem,UpdateCategoryItem,RemoveCategoryItem(lines 131-206) live onCategory, never onCategoryItem; each emits aCategoryItemChangedfrom the root (e.g. line 150) so observers learn the aggregate boundary changed. - The private list enforces encapsulation.
_categoryItemsisprivate readonly List<CategoryItem>(line 27); the public surface is the read-onlyCategoryItems(line 31). EF still materializes the backing field via the private parameterless constructor (line 34).
[Rubric §8, Data Architecture](cascade soft-delete is orchestrated by the aggregate, not the handler):Delete()(lines 102-120) cascade-soft-deletes every active child in domain code (BR-71) before raisingCategoryChanged(Deleted).- All child mutations route through the parent.
Walkthrough
- Marker
[IdValueGenerated](line 15): Category PKs are DB-generated; Sessionize imports still supply explicit ids viaIDENTITY_INSERT. - Fields (lines 19-31):
Title,Sort,Type?, the private list, andCategoryItemstagged[Navigation(IsCollection = true)](ADR-002, signals the populator this is a child collection). - EF ctor (line 34): parameterless, private, sets
Title = string.Emptyto satisfy the non-nullable field before EF assigns columns. Create(lines 54-75): validate viaCategoryInvariants.EnsureTitleIsValidthen resolve whether the id is DB-generated (typeof(Category).IsIdValueGenerated, line 65) then construct with the computedIdthenAddDomainEvent(new CategoryChanged(Added, …)). The canonical validate then construct then emit shape.AddCategoryItem(lines 131-153): uniqueness check (BR-138) viaCategoryInvariants.EnsureCategoryItemNameIsUniquethen delegate construction toCategoryItem.Createthen add to the private list then emit the change event. Callers nevernew CategoryItem(...).SetCategoryItems(line 210):internalhook used only by the navigation populator after a cross-source load;SetItems(_categoryItems, …)replaces the in-memory list without raising domain events (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 consistency rules in one place. ADR-002 explains why
SetCategoryItemsexists: when category and items share a database EFInclude()loads them together; when they could cross databases the populator queries separately and callsSetCategoryItems, the aggregate is agnostic to the path.Where it's used: loaded by
IReadRepository<Category, …>, mutated by the Conference category command handlers (Application tier), and projected viaIEntityQueryServicefor 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:10· Level 5 · class (static), SCC withCategory,CategoryItem
- What it is: invariant rules for
Categoryand itsCategoryItemchildren: title validation, item-name validation, and case-insensitive uniqueness checking (BR-138). - Depends on:
CategoryItem(SCC),CommonInvariants(Level 3),Error(Level 1),Result(Level 2). - Concept: the Invariants-class pattern (see
EventInvariants); the distinctive method here is the collection-aware uniqueness guard, which the simpler invariant classes lack.[Rubric §4, DDD](the ubiquitous language, "an item name is unique within its category", expressed directly in the domain). - Walkthrough
TitleMaxLength(255) andCategoryItemNameMaxLength(500), note these arestatic readonly inthere (lines 13, 16) rather than theconst intused by the other invariant classes; both feed the EF column widths.EnsureTitleIsValid(line 18) /EnsureCategoryItemNameIsValid(line 23):Result.Combineof non-empty + max-length.EnsureCategoryItemNameIsUnique(lines 36-54): takes the existing item collection and an optionalexcludeItemId(so renaming an item to its own name during an update doesn't self-conflict). It skipsIsDeleteditems (line 43) and compares withStringComparison.OrdinalIgnoreCase(line 44), returningError.Conflicton a duplicate.
- Why it's built this way: co-locating invariants per aggregate without bloating the entity; the
Result-returning style composes withResult.Combine, and the uniqueness method takes the collection as a parameter so it stays a pure function (no repository, no EF) the aggregate can call in-memory. - Where it's used: called from
Category'sCreate/Update/AddCategoryItem/UpdateCategoryItemandCategoryItem'sCreate/Update; length constants read by the EF configurations.
CategoryItem
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:14· Level 5 · class (sealed), SCC withCategory,CategoryInvariants
- What it is: the child entity of
Category: a selectable option within a category (e.g. "Beginner" within "Level"). CarriesName,Sort, the back-navigationCategory?, and the FKCategoryId. - Depends on,
AuditableBaseEntity<TIdentifierType>(Level 3),Category(SCC),CategoryInvariants(SCC),Result(Level 2),IdValueGeneratedAttribute(Level 0),NavigationAttribute(Level 0). - Concept introduced, child entity vs. aggregate root.
[Rubric §4, DDD](the entity hierarchy within an aggregate). A child entity has identity (it extendsAuditableBaseEntity<T>) but is owned by a root and is never fetched directly from a repository, always loaded through its parent. ItsCreatefactory (lines 47-65) mirrors the root's validate then construct shape but emits no domain event, event emission is the root's responsibility. - Walkthrough
[Navigation] public Category? Category { get; set; }(lines 23-24): the back-navigation;setis public so EF can wire the navigation after materialization.CategoryId(line 27): the FK,get-only, never externally set.Create(lines 47-65): validates the name, resolvesIsIdValueGenerated(line 57), constructs. NoAddDomainEvent,CategoryraisesCategoryItemChanged.Update(lines 73-85): re-validates and setsName/Sort; again no event.
- Why it's built this way: the child stays lean (only its own field constraints) so callers cannot
bypass the parent's uniqueness/cascade rules by calling
categoryItem.Update(...)directly. - Where it's used: loaded through
Category(EFIncludeor navigation populator); referenced bySpeakerCategoryItemas the many-to-many bridge target.
Question
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Questions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:15· Level 5 · class (sealed)
- What it is: a standalone aggregate root for a survey/feedback question. A question targets an
entity type (
QuestionEntity: "Session"/"Event"/"Speaker"), has an input type (QuestionType: "Rating"/"Text"/"Email"), a sort order, anIsRequiredflag, and a source ("Sessionize"/"User"). Unlike the other aggregates here it owns no children, answers live on the answering entity (EventQuestionAnswer,SpeakerQuestionAnswer). - Depends on,
AuditableAggregateRootEntity<TIdentifierType>(Level 4),QuestionInvariants(Level 4),Result,DomainEntityState; domain eventQuestionChanged. - Concept: a "thin" aggregate root: the consistency boundary is just the question record itself.
Note it is not marked
[IdValueGenerated](the class header has no attribute, line 15), Question ids are explicitly assigned (e.g. Sessionize), andCreateresolves this through the sametypeof(Question).IsIdValueGeneratedcheck (line 88), which here returnsfalse(so theid!.Valuebranch is taken, line 92). - Walkthrough
Create(lines 71-98): fourQuestionInvariantschecks (text, entity, type, source) combined viaResult.Combine(lines 80-84); construct; emitQuestionChanged(Added)(line 95).Update(lines 109-132): re-validates text/entity/type but drops thequestionSourceparameter, source is immutable after creation (a business rule encoded by absence).Delete(lines 136-144): emitsQuestionChanged(Deleted).
- Why it's built this way: validating against
QuestionInvariants' closed value-lists (not free-form strings) means the domain rejects an invalid type/entity/source before persistence; makingQuestionSourcenon-updatable preserves the provenance distinction (user-created vs. imported). - Where it's used: referenced (by scalar FK
QuestionId) fromEventQuestionAnswerandSpeakerQuestionAnswer; fed into the feedback reports (Application/UI tiers).
Speaker
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:15· Level 5 · class (sealed), SCC withSpeakerCategoryItem,SpeakerQuestionAnswer
- What it is: the aggregate root for a conference speaker. Carries rich profile data (name, email
as an
Emailvalue object, bio, tag line, social/URL links,IsTopSpeaker), ownsSpeakerCategoryItemjoin entities (which category items, topics, locality, describe the speaker) andSpeakerQuestionAnswerchildren, and holds the cross-module linkLinkedUserId. IDs are Sessionize-assigned GUIDs. - Depends on,
AuditableAggregateRootEntity<TIdentifierType>(Level 4),SpeakerInvariants(Level 4),Email(Level 4),SpeakerCategoryItem(SCC),SpeakerQuestionAnswer(SCC),Result,Error,DomainEntityState,NavigationAttribute; domain eventSpeakerChanged,SpeakerCategoryItemChanged,SpeakerQuestionAnswerChanged. - Concept: the aggregate-root pattern (see
Category) plus a cross-module link field and value-object composition.[Rubric §7, Microservices Readiness]and[Rubric §8, Data Architecture]:LinkedUserId(line 55) is a nullable scalar FK toUserin the Identity database, it cannot be an EF navigation because the two entities live in different databases (ADR-006).Emailis a value object, so an invalid email can never be stored. - Walkthrough
FullName(line 58): a computed=>property ($"{FirstName} {LastName}"), not stored.Create(lines 110-157): parsesemailinto anEmailvalue object first (lines 120-127), if email is supplied but invalid the factory fails before the name checks, avoiding a partial error list, thenResult.Combines the name invariants. The id assignment (line 151) is the one that differs from its siblings:id ?? (isIdValueGenerated ? default : Guid.NewGuid()). The inline comment (lines 146-150) records why:SpeakerIdentifierTypeis a client-assignedGuid; when no id is supplied (organizer-created speakers and the seeder both passnull) it generates one rather than dereferencing a nullNullable, the oldid!.Valuethrew "Nullable object must have a value" and crashed Conference's startup seeding and every organizer "create speaker" call.Delete(lines 229-245): BR-70 cross-context cleanup. It capturesLinkedUserIdinto a local beforebase.Delete()(line 232), clearsLinkedUserIdwithin the Conference context (line 239), then emitsSpeakerChanged(Deleted, …, previousLinkedUserId)(line 241) so the cross-context integration-event handler can clearUser.LinkedSpeakerIdin Identity. The event carries enough data for the handler to act without a synchronous call back (ADR-003).LinkUser/UnlinkUser(lines 250-282): guard against already-linked / not-linked (BR-209), set or clearLinkedUserId, and emitSpeakerChanged(Updated).AddSpeakerCategoryItem/RemoveSpeakerCategoryItem(lines 292-337) and theSpeakerQuestionAnswermanagement methods (lines 353-414): the same aggregate-manages-child shape.AddSpeakerCategoryItem(line 292) does run an in-memory duplicate guard (_speakerCategoryItems.Exists(...), lines 296-303), returningError.Invariant("Speaker.CategoryItem.Duplicate")before delegating to the child factory, the same shape asEvent.AddEventSpeaker.AddSpeakerQuestionAnswer(line 353), by contrast, has no duplicate guard, a speaker answering the same question twice is not blocked in the domain.SetSpeakerCategoryItems/SetSpeakerQuestionAnswers(lines 341, 418):internalpopulator hooks.
- Why it's built this way:
LinkedUserIdas a nullable scalar (not a navigation) is the direct consequence of database-per-service (ADR-006): the bidirectional User↔Speaker link is maintained through integration events (ADR-003) and gRPC, never a cross-database FK. (Per the project memory, speaker locality is modeled as aCategoryItem, not aSpeaker.Locationfield, hence theSpeakerCategoryItemcollection rather than a scalar location property.) - Where it's used: read by
IEntityQueryService, mutated by the speaker handlers, and referenced (by FK) fromEventSpeaker, the Engagement bookmark entities, and the IdentityUser.
SpeakerCategoryItem
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:13· Level 5 · class (sealed), SCC withSpeaker,SpeakerQuestionAnswer
- What it is: the join entity linking a
Speakerto aCategoryItem. HoldsCategoryItemId(FK), back-navigationSpeaker?, and FKSpeakerId. DB-generated id. - Depends on,
AuditableBaseEntity<TIdentifierType>(Level 3),Speaker(SCC),Result,IdValueGeneratedAttribute,NavigationAttribute. - Concept: the same explicit-join-entity pattern as
EventSpeaker. This is the physical representation of how a speaker's topics and locality are tracked: rather than aSpeaker.Locationfield, locality is aCategoryItem(Category 121854 in the project notes) attached via this join. It is[IdValueGenerated](line 12).Create(lines 36-48) is a pure FK pair with no content validation and no domain events (SpeakerraisesSpeakerCategoryItemChanged). - Where it's used: loaded via
Speaker.SpeakerCategoryItems; consumed by speaker-detail and locality-report features.
SpeakerQuestionAnswer
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:13· Level 5 · class (sealed), SCC withSpeaker,SpeakerCategoryItem
- What it is: the child entity of
Speakerholding an answer to aQuestionfor the speaker (e.g. "T-shirt size?"). HoldsQuestionId(FK),AnswerValue, back-navigationSpeaker?, and FKSpeakerId. DB-generated id. - Depends on,
AuditableBaseEntity<TIdentifierType>(Level 3),Speaker(SCC),SpeakerInvariants(Level 4),Result,IdValueGeneratedAttribute,NavigationAttribute. - Concept: the child-entity discipline of
EventQuestionAnswer, differing only in parent and the invariant class used.Create(lines 46-64) andUpdateAnswer(lines 71-80) validate viaSpeakerInvariants.EnsureAnswerValueIsValid; no domain events (SpeakerraisesSpeakerQuestionAnswerChanged). - Where it's used: loaded via
Speaker.SpeakerQuestionAnswers; shown on speaker feedback UIs.
IEventCascadeDeletionDomainService
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Services·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/IEventCascadeDeletionDomainService.cs:12· Level 7 · interface
- What it is: a pure domain-service interface that coordinates the cascade soft-delete of an
Eventtogether with all of itsSessions (BR-127). Sessions are separate aggregates fromEvent, so they cannot be reached throughEvent.Delete()alone. - Depends on:
Event,Session(domain aggregates),Result. - Concept introduced, domain services for cross-aggregate coordination.
[Rubric §4, DDD](assesses domain services for logic that belongs to no single aggregate) and[Rubric §3, Clean Architecture](domain services live in the Domain layer with no infrastructure dependency). When a business operation spans two or more aggregate boundaries it belongs in a domain service, not in either aggregate. Deleting an event must also soft-delete its sessions (BR-127, BR-55), butEventandSessionhave separate identity and lifecycle. The service receives pre-fetched aggregates (loaded by the handler from the repository) and orchestrates the deletion purely in memory, no DB access, no repository calls. The interface lives in the Domain layer; the concreteEventCascadeDeletionDomainServicelives alongside it in the same namespace (Domain), not in Infrastructure. - Walkthrough: one method:
Result CascadeDelete(Event @event, IReadOnlyCollection<Session> sessions)(line 21). The handler loads the event and its active sessions (with children), then callsservice.CascadeDelete(event, sessions). - Why it's built this way: putting cascade logic in a domain service keeps the handler thin (fetch
then service then save), keeps the domain expressive (cascade deletion is a named business concept),
and keeps the domain infrastructure-free (it receives entities, not repositories).
[Rubric §14, Testability]: trivially unit-testable, pass domain objects, assertIsDeletedand domain events without touching EF. - Where it's used: consumed by
DeleteEventHandler(Application tier), which resolves the abstraction from DI. - Caveats / not-in-source: the prior tier guide described the implementation as living in
MMCA.ADC.Conference.Infrastructure; the source places both interface and implementation inMMCA.ADC.Conference.Domain.Services(the implementation is a pure domain service with no infrastructure dependency).
EventCascadeDeletionDomainService
MMCA.ADC.Conference.Domain ·
MMCA.ADC.Conference.Domain.Services·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Services/EventCascadeDeletionDomainService.cs:11· Level 8 · class (sealed)
- What it is: the concrete implementation of
IEventCascadeDeletionDomainService: a stateless, pure domain service that cascade-soft-deletes an event'sSessions and then theEventitself. - Depends on:
IEventCascadeDeletionDomainService,Event,Session,Result. - Concept: see
IEventCascadeDeletionDomainServicefor the domain-service rationale. This class is the smallest possible realization of it: no fields, no constructor, one method, no infrastructure references, the class doc comment (lines 7-10) states it explicitly ("Pure domain service -- no infrastructure dependencies"). Because it is stateless it is registered as a singleton in the Conference Application DI viaservices.TryAddSingleton<IEventCascadeDeletionDomainService, EventCascadeDeletionDomainService>()(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:43). - Walkthrough:
CascadeDelete(Event @event, IReadOnlyCollection<Session> sessions)(line 14):- Sessions first (lines 17-20):
foreachsession callsession.Delete()(BR-127; each session in turn cascades to its own children per BR-55). Note the per-sessionResultis not inspected here, the loop fires every delete unconditionally. - Then the event (line 23):
return @event.Delete(), which itself cascades to the event's owned children (rooms, event speakers, event question answers) per BR-72, and thatResultis returned. EachDelete()also queues the corresponding domain events on its aggregate, which the unit of work dispatches afterSaveChangesAsync.
- Sessions first (lines 17-20):
- Why it's built this way: keeping the orchestration in a tiny pure class makes the multi-aggregate delete a single named, unit-testable unit while leaving each aggregate responsible for its own internal cascade. The handler stays a thin fetch then coordinate then persist slice.
- Where it's used: resolved (via the interface) and invoked by
DeleteEventHandlerafter it loads the event and its active sessions; theResultit returns becomes the handler's outcome. - Caveats / not-in-source: the method returns only the event's
Delete()result, a failure inside an individualsession.Delete()is not surfaced as the method's return value (eachsession.Delete()is a soft-delete of anAuditableBaseEntity, which in the current base implementation does not fail under normal conditions).
⬅ Aspire Orchestration & Service Defaults • Index • ADC Conference - Application & Use Cases ➡