to navigate Enter to open "…" all these words ANDOR to combine

Onboarding guide

18. ADC Conference - Application & Use Cases

What this chapter covers. This is the application layer of the Conference module, the largest single application assembly in the codebase (this group covers 303 entries in the type map, 302 distinct names, since two private StatusBucket enums share a name in different decision-support slices). It sits between the REST/gRPC edge (G20, Conference API & gRPC) and the domain aggregates (G17, Conference Domain), and it is where the conference's use cases actually live: create an event, publish it, add a room, a speaker, a sponsor or a social activity, import the whole agenda from Sessionize.com, export the schedule as an .ics calendar, answer "what is happening right now", and run the AI-assisted analytics that help organizers decide which session proposals to accept. One slice cuts across several of those reads: the public-visibility projection that keeps non-published events, non-accepted sessions, their speakers, their rooms, their sponsors and their activities out of an unprivileged reader's results (BR-108 / BR-49 / BR-239). It is resolved in one place by PublicConferenceVisibility over the shared PublicSessionStatusSpecification allow-list, and nine GetPublic*Filter handlers (activities, rooms, event speakers, sessions, session category items, session speakers, speakers, speaker category items, and sponsors) turn its id lists into specifications the Conference controllers apply. Everything here is engine-agnostic and framework-light: it depends on the abstractions introduced by MMCA.Common.Application (handlers, handler bases, mappers, validators, query services, navigation populators) and on the Conference domain, but never on EF Core, ASP.NET, or a broker SDK directly. Read the primer's tour of CQRS and Vertical Slice first; this chapter shows those styles at full scale in one module.

The vertical-slice anatomy of a use case

Open any feature folder under Sessions/UseCases/, Events/UseCases/, Speakers/UseCases/, Sponsors/UseCases/, Activities/UseCases/, Categories/UseCases/, or Questions/UseCases/ and you will find the same cohesive slice: a command or query record, its handler, its FluentValidation validator, and (for creates) a request record plus a request mapper, all co-located. Adding a feature means adding a folder, not threading an edit through horizontal Services/, Validators/, and Repositories/ directories. This is the Vertical Slice discipline made physical. [Rubric §5, Vertical Slice] assesses whether a feature is one navigable unit rather than scattered horizontally, and the folder layout is the evidence.

A command mutates and returns a Result; a query is side-effect-free and returns a Result<TDTO>. Both implement the Common contracts ICommandHandler<in TCommand, TResult> and IQueryHandler<in TQuery, TResult>, so every handler in this assembly flows through the same decorator pipeline (Logging, Caching, Transactional, then the handler) without knowing it exists. Commands that change cached read data implement ICacheInvalidating and publish the aggregate prefix the pipeline evicts (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:16-20, MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:16-20). Four commands must be atomic across several writes and additionally implement ITransactional: the Sessionize refresh (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13, which also carries IFeatureGated with the SessionizeIntegration flag at :19), the two speaker link commands (MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerCommand.cs:13, MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerCommand.cs:12), and BatchAddSessionQuestionAnswersCommand, which submits a whole feedback form so a partially applied form can never be observed (MMCA.ADC.Conference.Application/Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersCommand.cs:22-28). Exactly one read opts into caching: GetNowNextQuery implements IQueryCacheable with a 30-second TTL (MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:38) and a cache key built under the Session aggregate prefix, so session writes evict it (GetNowNextQuery.cs:26-35). The controller injects the handler interface and calls HandleAsync; the concrete type is invisible to it. [Rubric §6, CQRS & Event-Driven] assesses a clean command/query split through well-defined handler boundaries: this module is the canonical demonstration, dozens of single-responsibility handlers, each one slice wide, all dispatched uniformly.

The CRUD-shaped handlers do not spell out the load-validate-persist-map dance any more; they fill in the holes of a framework base class. CreateActivityHandler is the smallest complete example: it derives from CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> closed over the activity types and overrides exactly one member, the module-vocabulary log line (MMCA.ADC.Conference.Application/Activities/UseCases/Create/CreateActivityHandler.cs:20-24); the base runs the IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (validator plus the domain Create(...) factory, returning Result<TEntity>), persists through IUnitOfWork, and maps back with an IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>. CreateEventHandler, CreateSpeakerHandler, CreateSponsorHandler, CreateQuestionHandler and CreateConferenceCategoryHandler follow the identical shape. State transitions use the sibling base: PublishEventHandler derives from MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> and supplies three things, the id to load (MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:19), the client's row-version token so a decision taken against a stale view fails the save and answers 412 Precondition Failed rather than silently winning (:21-25, ADR-035), and the mutation itself, a one-line delegation to Event.Publish() (:28-32). The business rule ("an event's end date cannot precede its start date") lives in the domain factory and the invariant classes, never here.

DeleteEventHandler is the one delete that is not the generic framework handler: it eagerly loads the event's owned children (Rooms, EventSpeakers, EventQuestionAnswers, MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:28-33), then the event's separate Session aggregates (BR-127, DeleteEventHandler.cs:37-43), its Sponsor aggregates (DeleteEventHandler.cs:45-52, otherwise the public sponsor strip keeps reading orphaned rows) and its Activity aggregates (DeleteEventHandler.cs:54-61, same reasoning for the public activities page), and hands all four collections to the domain's EventCascadeDeletionDomainService (DeleteEventHandler.cs:64, BR-72/BR-55), because a cross-aggregate cascade is an application-layer decision. The Speaker and Question aggregates use the framework's DeleteEntityHandler<TEntity, TIdentifierType> bound closed in the composition root (MMCA.ADC.Conference.Application/DependencyInjection.cs:78, :84), and Category, Activity and Sponsor get theirs from the bulk AddEntityCrud calls described below.

The richer update handlers ride a third base, MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload>, which adds a MutationContext: a small keyed bag that lets MutateAsync stash something the result builder needs. That is how UpdateSessionHandler and UpdateEventHandler return the two-part records UpdateSessionResult and UpdateEventResult, each carrying the DTO plus a non-blocking warning flag. The session variant rejects immutable-field edits with Error.UnprocessableEntity (BR-140, MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:44-52), loads the parent event once for both the room checks and the BR-86 comparison (:54-63), and sets the "scheduled outside the event's date range" flag under a named key (:26, :98-100, computed at :122-131) that BuildResult reads back (:110-116). The event variant detects a BR-131 time-zone change before the mutation overwrites the stored value, and only pays for the "does this event have sessions" probe when the value actually moves (MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:43-57). Both stamp the client's concurrency token so a concurrent edit surfaces as a 412 instead of last-write-wins (UpdateSessionHandler.cs:31-35, UpdateEventHandler.cs:30-34, ADR-035).

Both the create and update session paths run the shared room checks through SessionRoomScheduling.ValidateRoomAssignmentAsync (MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:44, called at MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:111-119 and UpdateSessionHandler.cs:66-74). It enforces two rules: the requested room must belong to the session's own event (BR-130, SessionRoomScheduling.cs:59-67), and the requested slot must not double-book the room, probed with BuildOverlapPredicate (SessionRoomScheduling.cs:93), a SQL-translatable half-open interval comparison (s.StartsAt < endsAt && s.EndsAt > startsAt, SessionRoomScheduling.cs:103-106) so back-to-back sessions in one room do not collide, with int.MinValue as an "exclude nothing" sentinel that keeps the predicate a single shape (SessionRoomScheduling.cs:99-101). The class documents the overlap half as a deliberate soft guard (SessionRoomScheduling.cs:16-25): the existence probe and the write that follows are separate statements, so two concurrent organizer writes can both observe a free window; SQL Server has no range-exclusion constraint to express the rule as an index, and the trade-off is accepted because the endpoints are organizer-only and the outcome is repairable. Writing that reasoning down beside the code is [Rubric §34, Architecture Governance & Documentation] in practice.

CreateSessionHandler carries one more piece of orchestration on top of the create base. Session ids are app-assigned (the integer primary key is the Sessionize id), so an organizer create computes the next id in the reserved manual range bounded by SessionInvariants.ManualIdRangeStart/End inside the base's PrepareAsync hook (CreateSessionHandler.cs:79-95), and because two concurrent creates can compute the same id, the handler overrides HandleAsync to retry a bounded three times (CreateSessionHandler.cs:33, :60), each retry in a fresh DI scope because the ambient DbContext still tracks the failed insert (CreateSessionHandler.cs:54-58). An explicitly supplied id (a Sessionize import) is respected as-is and gets a single attempt (CreateSessionHandler.cs:42-43). The layering rule that forbids an EF Core or SQL-client reference here does not force string matching on the exception message: the handler injects the framework port IUniqueConstraintViolationDetector and asks it (CreateSessionHandler.cs:27, used in the catch filter at :60), so the provider-specific knowledge stays in infrastructure. That is [Rubric §29, Resilience & Business Continuity] applied at the write path (a genuinely concurrent case handled in code rather than pushed to the caller) and [Rubric §3, Clean Architecture] holding the line while doing it.

Manual mapping, validation rule fragments, and authorization specifications

Three sibling families recur across every aggregate. DTO mappers (SessionDTOMapper, EventDTOMapper, SpeakerDTOMapper, SponsorDTOMapper, ActivityDTOMapper, RoomDTOMapper, CategoryItemDTOMapper, and the question-answer / category-item link mappers) implement the Common mapper contract and are Mapperly source-generated: the class is [Mapper] and partial, and the mapping method is a partial declaration the generator fills in at compile time (MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:11-16). Child collections are delegated to the child's own mapper through [UseMapper] fields rather than re-declared (MMCA.ADC.Conference.Application/Sessions/DTOs/SessionDTOMapper.cs:20-30). This is the deliberate choice of ADR-001 (compile-time mapping over reflection-based AutoMapper), so a renamed property is a build error, not a silent null. Where a mapper needs a rule the generator cannot express it writes the method itself and calls the generated one: SpeakerDTOMapper does exactly that to strip the speaker's email for non-organizers (BR-66, MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:30-37), which is [Rubric §11, Security] and [Rubric §30, Compliance, Privacy & Data Governance] enforced at the one boundary every read crosses. [Rubric §9, API & Contract Design] assesses explicit, traceable contracts: the mapping is code you can read and test, not convention magic.

Validation is composed, not inherited. Small generic rule fragments (EventDateRangeRules<T>, EventNameRules<T>, RoomCapacityRules<T>, SessionTitleRules<T>, SpeakerFirstNameRules<T>, SponsorNameRules<T>, ActivityTimeRangeRules<T>, CategoryItemNameRules<T>, and three dozen siblings) each encapsulate one validated concern behind a property selector: the plain string ones subclass the framework's RequiredStringRules<T> and pass the domain's max-length invariant through (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:13-18), while the ones with real logic derive from AbstractValidator<T> directly, such as EventTimeZoneRules<T>, which additionally proves the value is a resolvable IANA identifier via TimeZoneInfo.FindSystemTimeZoneById (EventValidationRules.cs:25-48, BR-87). The optional fields compile the selector and wrap a shared fragment in a When(...) guard so an empty value is simply absent rather than invalid: EventOrganizerContactEmailRules<T> wraps EmailRules<T> (EventValidationRules.cs:62-65), and EventSponsorshipPacketUrlRules<T> and EventTicketingUrlRules<T> wrap AbsoluteUrlRules<T> (EventValidationRules.cs:82-85 and :102-105), whose scheme check keeps a rendered link from carrying an executable javascript: or data: target. EventDateRangeRules<T> is the richest, compiling the StartDate selector into a delegate (EventValidationRules.cs:126) and reading it inside a cross-property Must on EndDate (EventValidationRules.cs:127-129); ActivityTimeRangeRules<T> is the same shape one aggregate over (MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:105-108). Every rule carries a stable error code alongside its message (EventValidationRules.cs:30-32), so clients and tests key off the failure without string-matching prose.

The fragments are assembled twice, not per validator. Each aggregate declares the field set its create and update requests share as an interface (IEventFieldsRequest, ISessionFieldsRequest, ISpeakerFieldsRequest, IActivityFieldsRequest, ISponsorFieldsRequest), and one generic *FieldRules<T> constrained to it includes every fragment once (EventFieldRules<T> at EventValidationRules.cs:139-150, and its ActivityFieldRules<T>, SessionFieldRules<T>, SpeakerFieldRules<T> and SponsorFieldRules<T> siblings). The per-use-case validators then shrink to an Include(...) plus their own delta: EventUpdateRequestValidator is one include and one update-only enum check for the BR-233 moderation default the create request does not carry (MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:11-18). The pattern mirrors the framework's rule-fragment families in G06, Validation. [Rubric §24, Forms, Validation & UX Safety] and [Rubric §1, SOLID] both apply: fragments compose without an inheritance chain, and a new constraint is a new fragment that touches no existing validator.

Authorization and scoping specifications are the read-side half of the same story, and the module keeps exactly two of them. PublishedEventSpecification filters events to e => e.IsPublished (BR-108, MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:14), and PublicSessionStatusSpecification holds the BR-49 status allow-list as a static readonly Expression so the predicate can be composed into other expressions rather than only applied as a specification (MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:23-27). The allow-list is deliberately positive ("Status is null, or Status is Accepted") rather than a list of exclusions, and it compares against the constant instead of calling SessionStatuses.IsEligible, because a compiled method would not translate to SQL (PublicSessionStatusSpecification.cs:12-19). Both are Specification<TEntity, TIdentifierType> implementations passed into the query services to scope what a caller may read, which keeps the authorization predicate a reusable, testable expression rather than an if buried in a controller. [Rubric §11, Security] assesses authorization that is data-scoped, not just endpoint-gated.

The composition point above them is PublicConferenceVisibility (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:28), a static resolver with three public methods and one rule each: published event ids (BR-108, :36-48), visible session ids (the BR-49 allow-list ANDed with the published-event scoping, built through the framework's CrossSourceSpecification helper, :57-82), and visible speaker ids (BR-239: the speakers of at least one eligible session inside the scoped event set, :104-134, over a private helper that re-applies the allow-list inside an already narrowed scope, :141-158). Everything is expressed as scalar id projections rather than navigation joins, so the criteria stay translatable on any engine (ADR-018) and each aggregate keeps its by-id boundary to the others (PublicConferenceVisibility.cs:22-26). A scoped request narrows the rule to one event and only while that event is published, so an unpublished or unknown scope has no public speakers at all rather than erroring (:111-120). The speaker rule's remarks record a real leak that shaped it: the EventSpeaker join is deliberately not a visibility grant, because the Sessionize import writes a row for every speaker in the response, so reading it as one published the whole imported roster and made the filter vacuous (PublicConferenceVisibility.cs:97-103).

Several query handlers build a specification instead of returning data, and they exist because a navigating predicate (s => s.Event.IsPublished) is not translatable once the two entities can live in different data sources (ADR-006, ADR-018). GetPublicSessionFilterHandler uses the same CrossSourceSpecification helper to resolve the published Event ids and return a translatable Session.EventId IN (...) filter, ANDed with the shared status allow-list (MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:29-38). GetPublicSponsorFilterHandler, GetPublicActivityFilterHandler and GetPublicRoomFilterHandler do the simplest version of the same move, wrapping the published-event id list in an InlineSpecification<TEntity, TIdentifierType> (MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25-30, MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:25-30, MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:25-30). GetSessionsBySpeakerFilterHandler and GetSpeakersByEventFilterHandler hand-roll the same id-list shape against the link tables, projecting ids through GetReadRepository(...).GetProjectedAsync and materializing them once so the predicate embeds a stable collection EF can translate to IN (MMCA.ADC.Conference.Application/Sessions/UseCases/GetSessionsBySpeakerFilter/GetSessionsBySpeakerFilterHandler.cs:30-43, MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterHandler.cs:28-53, which unions the direct EventSpeaker links with the transitive SessionSpeaker ones). An empty id list correctly matches nothing, which is why the caller must still apply the specification rather than skip it (GetSessionsBySpeakerFilterHandler.cs:15-19).

Query services, navigation populators, and the composition root

Read paths do not get bespoke handlers for the common cases; they go through the framework's generic IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, which supplies filtering, sorting, paging, and field projection (ADR-034). The one local specialization is SpeakerEntityQueryService, a thin subclass of EntityQueryService<TEntity, TEntityDTO, TIdentifierType> that overrides only the DTO-to-entity property map so API consumers can sort and filter on the computed FullName while the pipeline translates it to (FirstName + " " + LastName) (MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:28-34). Eager-loading of child graphs is delegated to per-aggregate INavigationPopulator<in TEntity> implementations (EventNavigationPopulator, SessionNavigationPopulator, SpeakerNavigationPopulator, ActivityNavigationPopulator, SponsorNavigationPopulator, ConferenceCategoryNavigationPopulator, plus one per child entity), which encapsulate which navigations to include and how to batch-load cross-source relationships (ADR-002); the childless Question aggregate uses the framework's NullNavigationPopulator<TEntity> because it is never the root of a full graph load (MMCA.ADC.Conference.Application/DependencyInjection.cs:85).

All of this is wired by DependencyInjection, the module's composition root (MMCA.ADC.Conference.Application/DependencyInjection.cs:49, with the registration surface exposed as a C# extension(IServiceCollection) member at DependencyInjection.cs:48-50). It explicitly binds the closed generics Scrutor cannot infer (the cascade-deletion domain service at :55, the AI scoring queue at :61-62, then each aggregate's navigation populator, query service, and, where the module owns one, delete handler at :65-120, plus the two cross-module validation services at :123 and :126), and then calls ScanModuleApplicationServices<ClassReference>() (DependencyInjection.cs:130) to discover the "many small things" (every handler, mapper, validator, applier, and event handler) by convention. AssemblyReference and ClassReference are the marker types that anchor that scan (MMCA.ADC.Conference.Application/AssemblyReference.cs:5 and :11).

The ordering of the last block is load-bearing and the file says so. Three plain-CRUD aggregates get their create, update and delete verbs from one AddEntityCrud call each (Category, Activity, Sponsor, DependencyInjection.cs:140-142), and Speaker gets the derived-command update path because BR-214's CallerIsOrganizer is state the request body must never hold (DependencyInjection.cs:150). Those calls sit after the scan on purpose: they use TryAdd, so the module's own CreateXHandler (already registered by the scan, and the only one of the three verbs that still carries module vocabulary in a log line) keeps the create verb, while the update and delete verbs, which nothing in this assembly registers, come from the framework. The mutation itself still lives on the aggregate, reached through the *UpdateApplier the scan picked up beside the create mapper (ConferenceCategoryUpdateApplier, ActivityUpdateApplier, SponsorUpdateApplier, and the command-aware SpeakerUpdateApplier, which implements IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, in TCommand> rather than the request-only IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType> at MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateApplier.cs:13-14). [Rubric §7, Microservices Readiness] assesses clean, explicit module boundaries: every internal concrete type is reachable only through an interface registered here, which is exactly what lets the Conference module boot as its own service host. [Rubric §33, Developer Experience]: one file is the single place a new aggregate gets registered.

Event-driven reactions: domain and integration handlers

The application layer is also where the module reacts to events. The module keeps exactly one domain event handler, and it is the one with a real side effect. SpeakerDeletedHandler implements IDomainEventHandler<in TDomainEvent> over the entity's single lifecycle event, SpeakerChanged, and switches on its state discriminator, the taxonomy ADR-083 settles: everything but DomainEntityState.Deleted returns immediately (MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:29-30). On a delete with a previously linked user it opens its own DI scope (the handler is a singleton), resolves IEventBus, and publishes SpeakerUnlinkedFromUser so Identity can clear User.LinkedSpeakerId (SpeakerDeletedHandler.cs:38-45, BR-70). The LinkedUserId on the Conference side was already cleared inside Delete(), so this is the cross-module half of the cleanup and is deliberately eventually consistent (SpeakerDeletedHandler.cs:10-19).

The write side of the same link works in the other direction and inside the transaction: LinkUserToSpeakerHandler derives from MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>, enforces the BR-208 one-speaker-per-user guard (MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:42-55), and raises SpeakerLinkedToUser on the aggregate before the save, so the outbox row is captured in the same SaveChangesAsync as the link (:57-64, ADR-003).

The integration event handler UserRegisteredHandler is the cross-module boundary in the other direction: when Identity publishes UserRegistered over the broker, Conference auto-links a speaker to that user (BR-207). It does not implement IIntegrationEventHandler<in TIntegrationEvent> directly; it derives from ScopedIntegrationEventHandlerBase<TIntegrationEvent> (MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:45-48), which supplies the per-event DI scope a singleton needs to resolve a scoped IUnitOfWork and the log-and-propagate envelope around the body; the handler overrides HandleScopedAsync (:51-105) and customizes only the failure log line (:126-127). The only auto-link signal is an email match. The handler resolves the address through the Email value object (normalized to lowercase, so the comparison is effectively case-insensitive) and orders the candidates so an unlinked speaker wins and the choice stays deterministic when an address is shared (UserRegisteredHandler.cs:129-157). On a miss it runs a read-only name-match probe that counts unlinked speakers with the same first and last name and logs the count (:166-192): the matched rows are never returned, so no code path can link on a name. Name-based auto-linking was removed for security (bug hunt C5), and the documented consequence is that a legitimate speaker who registers after a name-only Sessionize import (the public feed omits PII, so Email is null) no longer links by itself, with that log line as the trail an organizer follows to link them manually through PUT /Speakers/{id}/link (BR-209, :17-26, :159-165). On a hit the handler links, saves, and publishes SpeakerLinkedToUser back to Identity (:86-104).

Two details of that handler are worth reading closely. Failures are not swallowed: the base logs and lets the exception propagate, and the remarks record the change of mind (:113-125). Swallowing meant one transient database fault lost the auto-link permanently, because the delivery was already acked, so letting the exception through hands the retry decision to the delivery mechanism, which is built for it (the outbox retries then dead-letters, MassTransit redelivers then moves the message to the error queue). Retrying is safe, because the "already linked to a different user" guard (:70-74) makes the second attempt a no-op, and the same guard resolves the rare unique-index race on Speaker.LinkedUserId (:93-97). Publishing flows through the IEventBus abstraction and the outbox (ADR-003), so the application code never references MassTransit. [Rubric §6, CQRS & Event-Driven] and [Rubric §7, Microservices Readiness]: the module collaborates through events and interfaces, never direct cross-module type references.

Two in-process services close the loop with the Engagement module. SessionBookmarkValidationService (MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:12) and EventLiveValidationService (MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:24) implement the Engagement-facing contracts ISessionBookmarkValidationService and IEventLiveValidationService, which Engagement calls via gRPC when the modules run as separate services (ADR-007). The former gates session bookmarking through the domain's SessionInvariants (EnsureNotServiceSession for BR-91, EnsureStatusIsEligible for BR-49, SessionBookmarkValidationService.cs:32-38), so the eligibility rule is the domain's, not a copy, and also answers the "which sessions belong to this event" lookup Engagement needs for event-scoped reads (SessionBookmarkValidationService.cs:42-54). The latter answers four live-layer questions (the event's window at EventLiveValidationService.cs:25, a session's at :48, a sponsor booth's at :104, and which session a room is hosting right now at :143, so a check-in never has to trust a client-supplied session id) and deliberately does not compute the window itself: it delegates to the domain's CurrentEventSelector.GetLiveWindowUtc (EventLiveValidationService.cs:223-230) so the midnight-to-midnight rule and the spring-forward-gap guard stay identical to the ones the home surfaces and the now/next snapshot use. Its session variant adds the assigned speaker ids (BR-236), the plenum flag, and the event's question-moderation default (BR-233, EventLiveValidationService.cs:90-100) after re-applying the BR-91 and BR-49 eligibility checks (:65-73). The traffic also runs the other way: GetSessionBookmarkCountsHandler re-verifies server-side that every requested session really belongs to the speaker before delegating the counting to Engagement's batched IBookmarkCountService, silently dropping ids that are not the speaker's rather than failing the whole batch (MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionBookmarkCounts/GetSessionBookmarkCountsHandler.cs:40-46), which is [Rubric §11, Security] (never trust the client's id list) and [Rubric §12, Performance & Scalability] (one cross-service round-trip instead of one per session) in a single handler.

Attendee-facing read models: calendar export and Now/Next

A small cluster of queries serves the public schedule surfaces without going through the generic query service, because their output is not a DTO list. ExportEventCalendarHandler and ExportSessionCalendarHandler return a Result<string> holding an .ics document: the event variant loads the event with its rooms, refuses unpublished or unknown events with Error.NotFound (MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:25-32), and turns every exportable session into one VEVENT with the room as its location (:43-51), with CalendarExportMapper doing the entity-to-entry shaping and owning the IsExportable eligibility rule, which itself defers the status half to SessionStatuses.IsEligible so no second copy of the allow-list can drift (MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:26-28), and the framework's IcsCalendarBuilder assembling the document (ExportEventCalendarHandler.cs:53). The export is role-independent by design: a privileged caller gets the same filtered document (CalendarExportMapper.cs:19-25). The time-zone lookup has no fallback and says why: EventInvariants.EnsureTimeZoneIsValid guards every write path, so an unresolvable stored id is a data defect that must surface rather than quietly degrade to UTC (ExportEventCalendarHandler.cs:39-41, and the same comment on the two sibling handlers at MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:47-49 and MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextHandler.cs:43-45).

GetNowNextHandler builds the conference-day "happening now plus next up" snapshot for one published event or, when no id is given, the auto-selected current-or-next published event using the domain's CurrentEventSelector rule (GetNowNextHandler.cs:84-95), reusing the same CalendarExportMapper.IsExportable rule and the same DST-aware wall-clock conversion as the calendar export (:47-50, :74-82) and reporting the event's live flag from the same GetLiveWindowUtc window (:68-69). "Now" is every row whose window straddles the current instant, ordered by start then room (:52-56), and "next" is the whole batch sharing the earliest future start, so parallel tracks surface together rather than one arbitrary winner (:58-66). GetNowNextHandler injects TimeProvider rather than reading the clock directly (:22, :29), which is what makes its "now" unit-testable at a fixed instant; the two export handlers instead stamp the .ics DTSTAMP from DateTimeOffset.UtcNow directly (ExportEventCalendarHandler.cs:53, ExportSessionCalendarHandler.cs:51-54), so their timestamp is not injectable. [Rubric §14, Testability].

The Sessionize import: Strategy-pattern orchestration

The single most involved use case is importing a conference agenda from Sessionize.com. Sessionize returns one JSON payload covering five interdependent entity families (categories, rooms, questions, speakers, and sessions, where sessions reference rooms and speakers and speakers reference categories). The HTTP shape is captured by a set of deserialization records (SessionizeResponse, SessionizeSession, SessionizeSpeaker, SessionizeRoom, SessionizeCategory, SessionizeCategoryItem, SessionizeQuestion, SessionizeQuestionAnswer, SessionizeLink) whose [JsonPropertyName] attributes are the only place the provider's wire vocabulary appears (MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:6-22), an anti-corruption boundary so a Sessionize API change never touches a domain entity.

The import is fetched through the ISessionizeService port (implemented by the HTTP client in G19, Conference Infrastructure) and orchestrated by RefreshFromSessionizeHandler (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:20). That handler does orchestration only: load the Event with its rooms and speakers eagerly (:43-48), enforce BR-6 (a Sessionize code must be configured, :56-64) and the BR-63 five-minute throttle (:66-75, measured against an injected TimeProvider), then call the external API inside a try/catch whose filter converts any "no usable data right now" condition into an Error rather than a 500. That filter is worth reading: because the client runs behind the standard resilience pipeline, an unreachable API arrives as a Polly TimeoutRejectedException or BrokenCircuitException as often as an HttpRequestException, and an HTML error page served with a success status arrives as a JsonException or NotSupportedException, so all five share one friendly failure (:83-93, :156-171), with an explicit ThrowIfCancellationRequested() first so caller cancellation is never reported as an upstream outage (:86).

The handler then runs five ISessionizeSyncStrategy implementations in dependency order (CategorySyncStrategy, RoomSyncStrategy, QuestionSyncStrategy, SpeakerSyncStrategy, SessionSyncStrategy, declared at :28-35 and invoked at :123-126), each carrying its work via a shared SessionizeSyncContext (:114-120) and returning a SessionizeSyncResult with a primary and an optional secondary count (ISessionizeSyncStrategy.cs:21-28). An empty response is treated as success, not an error, and still stamps the refresh (:95-111). Each strategy bulk-loads its entity family in one call (no N+1, MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:21-29), upserts via the domain's Create/Update methods (SessionSyncStrategy.cs:73-115), skips soft-deleted rows (BR-136, SessionSyncStrategy.cs:79-85, warned once at RefreshFromSessionizeHandler.cs:128-131), and accumulates human-readable warnings through SessionizeSyncWarnings instead of aborting on one bad record (SessionSyncStrategy.cs:104-110, plus the BR-86/BR-122 time warnings at :53-71). A final RequestIdentityInsert() (RefreshFromSessionizeHandler.cs:138) lets SQL Server accept Sessionize's own integer IDs before one batched SaveChangesAsync (:139). [Rubric §2, Design Patterns] (Strategy solving a real Open/Closed problem: a new entity family is a new strategy, not an edit to the orchestrator), [Rubric §12, Performance & Scalability] (bulk loads plus a single save round-trip), and [Rubric §17, DevOps & Deployment] (the throttle, the feature gate, and graceful per-entity degradation make a re-import safe to run repeatedly).

Decision support: AI scoring and content analytics

The last cluster is session-selection decision support, analytics that help organizers triage proposals. GetSessionSelectionDashboardHandler is a composite query: it validates the event, then loads the event's non-service sessions with their speakers and category items, the categories, and the referenced speakers, each in one call (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:23-63), and computes summary counts, category distribution, speaker-session overlap, content similarity, and speaker locality into one dashboard DTO. The speaker load deliberately runs with query filters off (:52-62): speaker deletion does not cascade to the SessionSpeaker link (BR-70/BR-71), so filtering here would render a real speaker as "Unknown" instead of the truth, and every downstream consumer re-filters the child collections in memory anyway. The narrower queries (GetCategoryDistributionQuery, GetContentSimilarityQuery, GetSpeakerSessionOverlapQuery) remain dispatchable individually. Content similarity is computed by the internal, pure-static SessionSimilarityCalculator, a weighted sum of two Jaccard indices, category-item overlap at 60% (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/SessionSimilarityCalculator.cs:11) and span-tokenized keyword overlap at 40% (:12, with a FrozenSet stop-word list at :14-34 that includes conference-generic words like "SESSION" and "WORKSHOP", and a three-character minimum token length at :62), combined at :97-105, above a tunable threshold whose default is 0.3 (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityQuery.cs:6), flagging proposals that would compete for the same audience. SpeakerLocalityHelper resolves a speaker's locality tier through the "where are you traveling from" category assignment rather than a Speaker field (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelper.cs:36-38), keeping a LocalityLookupEntry per item so a speaker carrying items from several yearly imports resolves to the most recent category (SpeakerLocalityHelper.cs:6-15), with the original Sessionize category id 121854 as the fallback when no title matches the heuristic (SpeakerLocalityHelper.cs:27). The two private per-handler StatusBucket enums map SessionStatuses values into display groupings (GetSessionSelectionDashboardHandler.cs:314-319 and MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94).

The flagship is AI scoring, and it is the one use case in this assembly that does not run on the request thread. Scoring an event takes minutes and issues one paid Anthropic call per session, so the API controller does not await it: it calls ISessionScoringQueue.TryEnqueue and turns the returned SessionScoringEnqueueResult into 202 Accepted or a conflict (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ISessionScoringQueue.cs:4-14, consumed by SessionSelectionController). That bounded-queue-plus-hosted-drain shape is the pattern ADR-052 settles for the whole codebase. SessionScoringQueue is a bounded Channel of capacity 16 (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:38) with SingleReader set and FullMode = Wait paired with a non-blocking TryWrite (:43-49), so a full queue refuses the new request outright instead of dropping an earlier one, which matters because each run is expensive and the caller needs to know it was not accepted (:26-32). It claims the event id in a ConcurrentDictionary before writing, so a concurrent duplicate loses the TryAdd and is refused rather than silently coalesced (:64-77); the claim is released only by MarkCompleted after the run finishes (:110), so the dedup window covers execution too, not just the wait in the queue. A queued SessionScoringWorkItem carries its attempt number (:21) so the drain worker can schedule a bounded retry through TryRequeue (:93-103) without keeping per-event state of its own, which the type documents as an intentional floor: an in-process, best-effort queue loses the retry if the process dies, and an organizer can always trigger the run again (:9-14). The interface's own remarks record what all of this replaced: a fire-and-forget task started from the controller that nothing tracked, nothing deduplicated, and nothing could cancel at shutdown (ISessionScoringQueue.cs:16-30). The single drain worker lives in infrastructure (SessionScoringProcessor), which is why DependencyInjection registers the concrete queue and the interface as the same singleton instance (DependencyInjection.cs:61-62): two instances would mean producers writing to a queue nobody drains. [Rubric §12, Performance & Scalability] and [Rubric §29, Resilience & Business Continuity].

The run itself is ScoreEventSessionsCommand, handled by ScoreEventSessionsHandler: a full re-score that loads every non-service session for the event (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsHandler.cs:30-34), batch-loads the speakers into a lookup (:39-51), then scores session by session through IAiScoringService, a port implemented in infrastructure by AnthropicScoringService, which calls the Anthropic Messages API. The replace strategy is the detail worth studying, because the comment records a reversal (:94-103): an earlier version deleted every score on the event up front in one set-based ExecuteDeleteAsync so the dashboard would reset to zero and count up, but that paid for the progress animation with real data, since N sequential paid calls follow and the first one to fail on an expired key or a rate limit left every session it never reached with no score at all. Today the delete is per-session and happens in the same step that writes the replacement (:104-108), so a run that dies partway through has replaced only what it re-scored; the unique filtered index on SessionId makes the delete-then-add pair safe. Each successful score is saved immediately (:107-108) so the UI can still show real-time progress, a per-session save failure only increments a counter and continues (:113-117), and the command fails as a whole only when every session failed (:127-133). The contract is deliberately never-throw: ScoreSessionAsync returns a SessionScoringResult with a Success flag and seven 1.0-10.0 sub-scores (overall, topic relevance, description quality, novelty, actionable takeaways, depth/insight quality, credibility/experience) in all cases (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:9, IAiScoringService.cs:40-71), so one bad session never aborts the scoring loop. The input shapes are SessionScoringInput and SpeakerInfo (IAiScoringService.cs:23-37); the result is mapped onto the SessionAiScore aggregate for persistence (ScoreEventSessionsHandler.cs:79-83). Keeping the port here and the HTTP/LLM adapter in infrastructure is textbook [Rubric §3, Clean Architecture] (the application layer depends on an interface, never the SDK) and [Rubric §7, Microservices Readiness] (the AI provider is swappable behind one interface).

Taken together, this assembly is the codebase's most complete picture of how a use case is built here: a slice per feature, generic framework machinery for the repetitive 80% (query, validate, map, persist, populate, and now the create/mutate handler bases themselves), and bespoke handlers reserved for the genuinely complex 20% (the Sessionize import, the attendee-facing read models, and the decision-support analytics), each isolated behind a port or a strategy so it can evolve, be tested, and ultimately be extracted without disturbing the rest.

ActivityTimeRangeRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:92 · Level 0 · class (sealed)

  • What it is: the one fragment in the activity family that validates a pair of properties rather than a single field: both ends of an activity's time range must be present, and the end must be on or after the start.
  • Depends on: FluentValidation's AbstractValidator<T> (NuGet, primer §3) and System.Linq.Expressions.Expression<Func<T, ...>> (BCL). No first-party dependency: unlike its siblings in the same file it reads no domain constant and derives from no framework rule.
  • Concept, the module-local rule fragment. The fragment idiom itself (a tiny generic AbstractValidator<T> that a real validator folds in with FluentValidation's Include(...)) is taught in group-06 on RequiredStringRules<T> and its siblings. This file is the Conference module's own layer on top, and it has two shapes. Most of the activity fragments inherit a framework rule and hand it a field label plus a domain constant (see ActivityNameRules<T>). This one, the cross-field rule, cannot: a check that compares two properties needs the instance, not just one selected value, so it writes its own chain. The mechanism is worth learning once because every cross-field rule in the codebase uses it: FluentValidation's Must has an overload taking (instance, value), so the fragment compiles the other selector into a delegate at construction time and calls it against the instance inside the predicate. Every rule written by hand here also attaches a stable dotted error code with WithErrorCode(...) next to its human-readable message, so an API client or a test can key off Activity.EndTime.BeforeStart without string-matching English prose. [Rubric §24, Forms, Validation & UX Safety] assesses whether validation is reused rather than copy-pasted across create and update paths and whether failures are machine-addressable: the fragment plus the error code is this module's answer to both. [Rubric §1, SOLID]: each fragment carries exactly one field contract, so changing that contract is a one-line edit in one place.
  • Walkthrough: a two-selector constructor with a statement body, ActivityTimeRangeRules(Expression<Func<T, DateTime>> startTimeSelector, Expression<Func<T, DateTime>> endTimeSelector) (ActivityValidationRules.cs:95-97), building three rules:
    • RuleFor(startTimeSelector).NotEmpty(), message "You must enter a Start Time", code Activity.StartTime.Required (:99-100).
    • RuleFor(endTimeSelector).NotEmpty(), message "You must enter an End Time", code Activity.EndTime.Required (:102-103).
    • var startTimeFunc = startTimeSelector.Compile(); (:105) turns the start-time expression into an executable Func<T, DateTime> once, at fragment construction, not per validation call. The third rule then hangs off the end-time selector and uses the two-argument Must((instance, endTime) => endTime >= startTimeFunc(instance)) (:106-107), reporting "End Time must be on or after the Start Time" with code Activity.EndTime.BeforeStart (:108). Attaching the comparison to the end selector is what makes the error surface on the end-time field in the UI.
  • Why it's built this way: the class doc (ActivityValidationRules.cs:87-91) says the shape mirrors EventDateRangeRules, so the two schedule-bearing aggregates fail the same way for the same reason. The >= is deliberate: a zero-length activity passes, and the rule bans only an end before its start, which is exactly the comparison the domain guard makes as well (ActivityInvariants.EnsureTimeRangeIsValid, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:79-86, code Activity.TimeRange.Invalid). Request path and domain path therefore agree on the rule and differ only in the code they report, which is how a failure tells you which layer rejected the value.
  • Caveats / not in source: NotEmpty() on a non-nullable DateTime rejects default(DateTime), so a genuinely unset value is caught, but a caller that posts a real-but-implausible time (for example far outside the event window) is not: no range-versus-event check exists in this fragment. Both ends are event-local wall times, per the domain doc at ActivityInvariants.cs:71-73; nothing here converts or compares zones.
  • Where it's used: pulled in once, by ActivityFieldRules<T> (ActivityValidationRules.cs:136), which is what both ActivityCreateRequestValidator and ActivityUpdateRequestValidator include.

AssemblyReference

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/AssemblyReference.cs:5 · Level 0 · class (static)

  • What it is: a tiny static class exposing the Conference Application assembly and its short name as two static readonly fields, so any reflection-driven tooling has a strongly-typed handle on this assembly.
  • Depends on: System.Reflection (BCL) only.
  • Concept, the assembly-anchor type. [Rubric §5, Vertical Slice] assesses whether a module is a self-contained, discoverable unit; a per-assembly anchor is how reflection-based wiring names "everything in the Conference Application layer" without hard-coding a namespace or assembly string. There is a sibling anchor of the same name in every layer of the module (Conference Domain, Infrastructure, API) and in every other ADC module, so a caller can always name the exact assembly it means.
  • Walkthrough: two fields, no methods. Assembly = typeof(AssemblyReference).Assembly (AssemblyReference.cs:7) resolves the containing assembly from the type itself; AssemblyName = Assembly.GetName().Name ?? string.Empty (AssemblyReference.cs:8) reads the simple name and falls back to an empty string when the runtime reports null. Both are computed once at type load.
  • Why it's built this way: typeof(X).Assembly is refactor-safe (renaming the assembly, moving the file, or restructuring the namespace changes nothing), which is why the codebase prefers an anchor type over Assembly.Load("...") with a literal.
  • Where it's used: nothing in MMCA.ADC references this module's Application anchor today. The consumer pattern is visible one layer over: the Conference Infrastructure anchor is what the design-time EF factory feeds to AddConfigurationAssembly (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/DesignTimeSQLServerDbContextFactory.cs:43). The convention scan performed inside DependencyInjection anchors on ClassReference instead, because that framework API is generic over a type argument rather than over an Assembly value. The class is kept for symmetry with the other layers' anchors and for tooling that wants the Assembly object directly.

ClassReference

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/AssemblyReference.cs:11 · Level 0 · class

  • What it is: an empty marker class used purely as a typeof anchor for assembly scanning; it declares no members.
  • Depends on: nothing.
  • Concept, the scan-anchor marker. [Rubric §2, Design Patterns] assesses idiomatic registration wiring. The framework's scanning API is generic over an anchor type, so ScanModuleApplicationServices<ClassReference>() reads as "scan the assembly that contains ClassReference", that is, this Application layer. The class body is empty (AssemblyReference.cs:11); its only job is to be a compile-time-checked stand-in for the assembly.
  • Walkthrough: public class ClassReference { } on one line. No fields, no methods, and deliberately neither sealed nor static, because a static class cannot be used as a generic type argument.
  • Why it's built this way: a dedicated marker keeps the scan call site refactor-safe and avoids anchoring the scan on a real domain or handler type that might later move to another assembly.
  • Where it's used: passed as the type argument to services.ScanModuleApplicationServices<ClassReference>() in DependencyInjection (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133). The generic parameter it satisfies is TAssemblyMarker on ScanModuleApplicationServices, declared in MMCA.Common (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:163-165) with a where TAssemblyMarker : class constraint, and its body simply forwards typeof(TAssemblyMarker).Assembly to the Assembly-typed overload (:179).

GetPublicActivityFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterQuery.cs:13 · Level 0 · record (sealed)

  • What it is: a parameterless query record asking one question: "which activities may an anonymous or non-privileged caller see?". It carries no data at all, and the answer it triggers is a specification, not a page of rows.
  • Depends on: nothing. public sealed record GetPublicActivityFilterQuery(); is the entire type, a positional record with an empty parameter list.
  • Concept, the specification-returning query. Most CQRS queries return data. This family returns a filter: a Specification<TEntity, TIdentifierType> the caller then hands to the generic read pipeline, which ANDs it with whatever paging, sorting and field selection the request already asked for. [Rubric §11, Security] assesses whether authorization is enforced at the data boundary rather than trusted to the UI: expressing "public visibility" as a server-built specification means a non-privileged caller's query is narrowed before it reaches the database, no matter which endpoint or filter string they sent. [Rubric §6, CQRS & Event-Driven]: the visibility rule is a first-class query use case with its own handler, so it is unit-testable and reusable instead of being an if buried in a controller. The empty record is the CQRS convention taken to its logical end, the query has no inputs because the answer depends only on server state (which events are published) and never on the caller's arguments.
  • Walkthrough: no members. The teaching is in the two doc comments. The summary (GetPublicActivityFilterQuery.cs:3-7) states the business rule: an activity is publicly visible when the event it belongs to is published (BR-108), so an event still being assembled does not leak its social programme before announcement. The remarks (:8-12) state the shape choice: Activity carries a real EventId column, so the rule resolves to a published-event id list and comes back as an Activity.EventId IN (...) criteria; no navigation join is involved, so the criteria stays engine-portable and Activity keeps its by-id boundary to Event.
  • Why it's built this way: keeping the criteria to scalar id comparisons rather than a navigation join is the polyglot-persistence safeguard of ADR-018; a filter written this way translates on any supported engine, not just SQL Server. It also preserves the DDD rule that one aggregate references another by id (ADR-006 draws the same boundary at the storage level).
  • Where it's used: constructed by ActivitiesController inside its GetReadSpecificationAsync override (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Activities/ActivitiesController.cs:75) and handled by GetPublicActivityFilterHandler. It is one of the public-filter query family in this module, alongside GetPublicSessionFilterQuery, GetPublicSpeakerFilterQuery, GetPublicSponsorFilterQuery and the rest.

IActivityFieldsRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/IActivityFieldsRequest.cs:12 · Level 0 · interface

  • What it is: the read-only shape of the eight activity fields that the create request and the update request validate identically. Both request records implement it, and it is what lets the shared rule list be declared once.
  • Depends on: nothing first-party, nothing external. Eight get-only properties over string, string?, DateTime and int.
  • Concept, the shared-field contract behind a validator. [Rubric §15, Best Practices & Code Quality] assesses whether a constraint lives in exactly one place; [Rubric §24, Forms, Validation & UX Safety] assesses whether create and update paths can drift apart. Before a contract like this exists, the usual shape is two validators that each Include the same seven fragments, which is seven chances for the two paths to diverge on the next field added. Declaring the fields as an interface lets a single generic validator (ActivityFieldRules<T>) be written over where T : IActivityFieldsRequest, so each concrete validator keeps only its own per-operation delta. The interface is get-only on purpose: nothing validates through it, it is only read, so an implementing record can keep init-only setters and stay immutable.
  • Walkthrough: Name (IActivityFieldsRequest.cs:15, non-nullable, the one required string), Description (:18), StartTime (:21) and EndTime (:24) as event-local wall times, VenueName (:27, where empty means the main conference venue), VenueAddress (:30), VenueUrl (:33), and SortOrder (:36, the tie-breaker between activities starting at the same time). The nullability of each property is load-bearing: it is what decides at compile time whether a required or an optional fragment can be bound to it.
  • Why it's built this way: the remarks (IActivityFieldsRequest.cs:8-11) record the one field deliberately left off the interface. The owning event is carried and validated only by the create request, because moving an activity between events is a create plus a delete, so EventId is a create-only delta rather than a shared field. That single omission is what keeps ActivityEventIdRules<T> out of the shared rule set.
  • Where it's used: implemented by ActivityCreateRequest (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequest.cs:11, alongside ICreateRequest and ICacheInvalidating) and by ActivityUpdateRequest (.../Activities/UseCases/Update/ActivityUpdateRequest.cs:10, where it is the only interface the record implements). Consumed as the generic constraint of ActivityFieldRules<T> (ActivityValidationRules.cs:131).

ActivityEventIdRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:80 · Level 1 · class (sealed)

  • What it is: a reusable rule fragment that enforces "an activity must name the event it belongs to", applied to whichever EventIdentifierType property a caller points it at.
  • Depends on: RequiredIdRules<T, TId> (its base class, MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:142-148) and System.Linq.Expressions (BCL). The EventIdentifierType in the selector signature (ActivityValidationRules.cs:83) is the module's identifier alias, declared once as a global using in the Shared project and linked solution-wide, which is why no import for it appears in this file.
  • Concept: the module-local rule fragment taught on ActivityTimeRangeRules<T>, here in its inheriting form: the fragment writes no RuleFor chain of its own and instead binds a framework rule to a module-specific label and error code. [Rubric §1, SOLID]: one field contract per fragment, so changing it is a one-line edit in one place.
  • Walkthrough: one expression-bodied constructor, ActivityEventIdRules(Expression<Func<T, EventIdentifierType>> selector) (ActivityValidationRules.cs:83), whose whole body is the base call : base(selector, "an Event for the Activity", "Activity.EventId.Required") (:84). The base contributes RuleFor(selector).NotEmpty() with the message "You must specify {fieldName}" and the supplied error code (CommonValidationRules.cs:146-147), so the label reads as a full phrase with its own article: "You must specify an Event for the Activity". Because EventIdentifierType aliases int, NotEmpty() here rejects 0, the default of an unset id, rather than a null. The base's remarks (CommonValidationRules.cs:133-139) note that the same check covers a Guid key by rejecting Guid.Empty.
  • Why it's built this way: the XML doc above the class (ActivityValidationRules.cs:75-79) states the rule's reason in domain terms: activities are scheduled per event, so an unscoped activity has nowhere to appear. Encoding that as a request-level rule means the caller gets a field-addressed validation failure before any handler or aggregate is touched. Passing the error code explicitly is what makes the failure machine-addressable: the framework's WithOptionalErrorCode helper (CommonValidationRules.cs:30-32) attaches a code only when one is supplied, so a fragment that omits it inherits the base message and no code.
  • Where it's used: included by ActivityCreateRequestValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:15) and by that validator only, as the comment beside the call says (:13-14). ActivityUpdateRequestValidator does not include it, because ActivityUpdateRequest carries no EventId at all: its doc comment (.../Activities/UseCases/Update/ActivityUpdateRequest.cs:6-9) records the choice, moving an activity between events is a create plus a delete, so a mistyped id cannot silently relocate a published social event.

ActivitySortOrderRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:116 · Level 1 · class (sealed)

  • What it is: a reusable rule fragment enforcing that an activity's display sort order is non-negative.
  • Depends on: NonNegativeIntRules<T> (its base class, MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:122-127), System.Linq.Expressions (BCL). No domain constant.
  • Concept: the inheriting form of the module-local rule fragment, as on ActivityEventIdRules<T>. [Rubric §15, Best Practices & Code Quality] assesses whether a constraint lives in one place; a single fragment shared by the create and update paths, itself delegating to one framework rule, is that.
  • Walkthrough: one expression-bodied constructor, ActivitySortOrderRules(Expression<Func<T, int>> selector) (ActivityValidationRules.cs:119), body : base(selector, "Sort Order", "Activity.SortOrder.Negative") (:120). The base contributes GreaterThanOrEqualTo(0) with the message "Sort Order must be greater than or equal to 0" (CommonValidationRules.cs:125-126) and attaches the supplied code.
  • Why it's built this way: the base chosen is the non-negative one, not PositiveIntRules<T> (CommonValidationRules.cs:100), because zero is a legitimate "first in the list" position. The choice of base class is the business rule here, which is why the class doc (ActivityValidationRules.cs:112-115) says only "non-negative value" and nothing else.
  • Where it's used: pulled in once, by ActivityFieldRules<T> (ActivityValidationRules.cs:137), so both the create and the update path get it without either validator naming it.

ActivityDescriptionRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:25 · Level 7 · class (sealed)

  • What it is: a length-only rule fragment for the optional activity description.
  • Depends on: OptionalStringRules<T> (its base class, MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:53-58), ActivityInvariants (its DescriptionMaxLength constant), System.Linq.Expressions (BCL).
  • Concept: the module-local rule fragment (ActivityTimeRangeRules<T>) in its inheriting form. The four optional string rules in this file and ActivityNameRules<T> write no RuleFor chain at all: they subclass a framework fragment and pass it a field label plus the domain's max-length constant. That is the module's whole contribution here, binding a generic rule to a domain invariant. [Rubric §15, Best Practices & Code Quality].
  • Walkthrough: one constructor, ActivityDescriptionRules(Expression<Func<T, string?>> selector) (ActivityValidationRules.cs:28), whose body is only a base call: : base(selector, "Activity Description", ActivityInvariants.DescriptionMaxLength) (:29). The base contributes MaximumLength(maxLength) and nothing else (CommonValidationRules.cs:56-57), so null and empty are both accepted. DescriptionMaxLength is 2000, declared on ActivityDTO (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:21) and re-exported as a const by the domain (.../MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:19).
  • Why it's built this way: the length is declared once on the DTO, which the Blazor pages bind their input caps to, and read from there by the domain invariants, the EF configuration and this validator (ActivityInvariants.cs:7-12 states exactly that chain), so a schema change cannot leave a stale validator behind.
  • Caveats: the inherited fragments pass no error code, so their failures carry a message but no stable code (WithOptionalErrorCode is a no-op when the code is null, CommonValidationRules.cs:30-32). And unlike the name and venue fields, which have domain-side guards emitting coded errors (ActivityInvariants.cs:36, :48, :58, :68), the description has only the constant at ActivityInvariants.cs:19 and no Ensure... guard beside it, so this fragment is the enforcement point on the request path.
  • Where it's used: pulled in by ActivityFieldRules<T> (ActivityValidationRules.cs:138), bound to IActivityFieldsRequest.Description.

ActivityNameRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:13 · Level 7 · class (sealed)

  • What it is: the required-string rule fragment for an activity's display name: non-empty and within the domain's maximum length.
  • Depends on: RequiredStringRules<T> (its base class, MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:41-47), ActivityInvariants (NameMaxLength), System.Linq.Expressions (BCL).
  • Concept: the inheriting form of the module-local rule fragment, as on ActivityDescriptionRules<T>. This one subclasses the required base rather than the optional one, which is the only structural difference between the two. [Rubric §1, SOLID], [Rubric §24, Forms, Validation & UX Safety].
  • Walkthrough: one constructor, ActivityNameRules(Expression<Func<T, string>> selector) (ActivityValidationRules.cs:16), body : base(selector, "Activity Name", ActivityInvariants.NameMaxLength) (:17). Note the non-nullable string selector, versus the string? of the optional siblings: the compiler enforces at the call site that only a required property can be passed here. The base contributes NotEmpty() plus MaximumLength(...) (CommonValidationRules.cs:44-46), with both messages built from the "Activity Name" label and the max-length message formatted through string.Create(CultureInfo.InvariantCulture, ...), which is what keeps the analyzers-as-errors build satisfied about culture-sensitive formatting. The ceiling is 200 (.../MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:18, re-exported at ActivityInvariants.cs:16).
  • Why it's built this way: the same value guards the request path here and the aggregate path in ActivityInvariants.EnsureNameIsValid (ActivityInvariants.cs:36-39), which emits the coded errors Activity.Name.Empty and Activity.Name.TooLong. Two layers, one constant, two distinguishable failures.
  • Where it's used: pulled in by ActivityFieldRules<T> (ActivityValidationRules.cs:135), bound to IActivityFieldsRequest.Name.

ActivityVenueAddressRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:49 · Level 7 · class (sealed)

  • What it is: a length-only rule fragment for the optional street address of an activity's venue.
  • Depends on: OptionalStringRules<T>, ActivityInvariants (VenueAddressMaxLength), System.Linq.Expressions (BCL).
  • Concept: the inheriting rule fragment taught on ActivityDescriptionRules<T>. [Rubric §15, Best Practices & Code Quality].
  • Walkthrough: one constructor (ActivityValidationRules.cs:52) delegating to : base(selector, "Venue Address", ActivityInvariants.VenueAddressMaxLength) (:53). The ceiling is 500 (.../MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:27, re-exported at ActivityInvariants.cs:25), the tightest of the four optional activity strings, and the domain doc records that it deliberately matches the event venue address (ActivityInvariants.cs:24). The domain-side guard is EnsureVenueAddressIsValid (ActivityInvariants.cs:58).
  • Where it's used: pulled in by ActivityFieldRules<T> (ActivityValidationRules.cs:140), bound to IActivityFieldsRequest.VenueAddress.

ActivityVenueNameRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:37 · Level 7 · class (sealed)

  • What it is: a length-only rule fragment for the optional name of the venue an activity happens at.
  • Depends on: OptionalStringRules<T>, ActivityInvariants (VenueNameMaxLength), System.Linq.Expressions (BCL).
  • Concept: the inheriting rule fragment taught on ActivityDescriptionRules<T>.
  • Walkthrough: one constructor (ActivityValidationRules.cs:40) delegating to : base(selector, "Venue Name", ActivityInvariants.VenueNameMaxLength) (:41), ceiling 200 (.../MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:24, re-exported at ActivityInvariants.cs:22). The class doc (ActivityValidationRules.cs:32-36) records the semantics the emptiness carries: an empty venue name means the main conference venue, which is precisely why this rule is the optional base and not the required one. Absence is meaningful data here, not a missing field.
  • Where it's used: pulled in by ActivityFieldRules<T> (ActivityValidationRules.cs:139), bound to IActivityFieldsRequest.VenueName.

ActivityVenueUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:63 · Level 7 · class (sealed)

  • What it is: the rule fragment for the optional external venue website URL: a length bound plus an absolute http/https scheme check, applied only when a value is present.
  • Depends on: AbsoluteUrlRules<T> (composed, not inherited, MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:85-94), FluentValidation's AbstractValidator<T>, ActivityInvariants (VenueUrlMaxLength), System.Linq.Expressions (BCL).
  • Concept, conditional composition inside a fragment. This is the one activity fragment that is neither a plain subclass nor a hand-written chain: it derives from AbstractValidator<T> and folds a framework fragment in under a When(...) guard. [Rubric §26, Front-End Security] and [Rubric §11, Security] both assess whether untrusted input that will later be rendered as a link is constrained at the boundary rather than at the point of rendering. The class doc (ActivityValidationRules.cs:56-62) names the exposure directly: the value cannot become an executable javascript: or data: link when the public activity page renders it. Doing the scheme check here, at the request boundary, means no consumer has to remember it.
  • Walkthrough: one constructor, ActivityVenueUrlRules(Expression<Func<T, string?>> selector) (ActivityValidationRules.cs:66), with a two-statement body.
    • var accessor = selector.Compile(); (:68) turns the selector into an executable delegate once, at construction, the same technique ActivityTimeRangeRules<T> uses for its cross-field comparison.
    • When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new AbsoluteUrlRules<T>(selector, "Venue URL", ActivityInvariants.VenueUrlMaxLength))); (:70-71). The When predicate is what makes an empty value pass outright, and the included framework fragment supplies both checks: MaximumLength(2000) and Must(BeAnAbsoluteHttpUrl) with the message "Venue URL must be an absolute http or https URL" (CommonValidationRules.cs:88-90). The scheme predicate delegates to CommonInvariants.EnsureUrlIsWellFormed (CommonValidationRules.cs:92-93), which passes a null or empty value and otherwise requires Uri.TryCreate(url, UriKind.Absolute, ...) to succeed with a scheme of exactly http or https (MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:293-297, :436-439). The ceiling is 2000 (.../MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:30, re-exported at ActivityInvariants.cs:28).
  • Why it's built this way: routing the scheme check through the same invariant the domain would call means the validator and the domain answer identically instead of drifting into two definitions of "a usable URL" (CommonValidationRules.cs:77-83 states that intent). A relative path, a bare host, and a javascript: payload all fail the same rule. Keeping the value a plain string rather than a Uri is also deliberate: the framework suppresses CA1054 on the invariant with the note that the point is to validate an untrusted string before anything turns it into a Uri (CommonInvariants.cs:289-292).
  • Caveats: the module's own domain guard for this field, ActivityInvariants.EnsureVenueUrlIsValid (.../MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:68-69), is still length-only: it calls EnsureOptionalStringMaxLength and emits Activity.VenueUrl.TooLong, with no scheme check. So the request path is stricter than the aggregate path for this one field, and a value written by a code path that bypasses the request validator would not be scheme-checked. Neither the framework fragment nor this one passes an error code, so a scheme failure carries a message but no stable dotted code.
  • Where it's used: pulled in by ActivityFieldRules<T> (ActivityValidationRules.cs:141), bound to IActivityFieldsRequest.VenueUrl.

ActivityFieldRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:129 · Level 8 · class (sealed)

  • What it is: the single fragment that composes all seven shared activity field rules, declared once over IActivityFieldsRequest. It is the only thing the update validator needs, and all but one line of what the create validator needs.
  • Depends on: IActivityFieldsRequest (its generic constraint, ActivityValidationRules.cs:131), the seven sibling fragments in the same file (ActivityNameRules<T>, ActivityTimeRangeRules<T>, ActivitySortOrderRules<T>, ActivityDescriptionRules<T>, ActivityVenueNameRules<T>, ActivityVenueAddressRules<T>, ActivityVenueUrlRules<T>), and FluentValidation's AbstractValidator<T>.
  • Concept, the composite fragment. The individual fragments answer "how is one field validated". This type answers "what is the field set", and the constraint where T : IActivityFieldsRequest (:131) is what makes the property selectors compile without knowing the concrete request type: p => p.Name binds against the interface, and the closed generic resolves to whichever record the validator supplies. [Rubric §1, SOLID] (open for extension: a new shared field is one interface property plus one Include here, and both request validators pick it up with no edit) and [Rubric §24, Forms, Validation & UX Safety] (the create and update paths cannot drift apart, because there is exactly one list) both land squarely on this type. [Rubric §14, Testability]: because the composite is generic and stateless, a test can instantiate it over either request type, or over a purpose-built stub implementing the interface.
  • Walkthrough: a parameterless constructor (ActivityValidationRules.cs:133) with seven Include calls, in the order name, time range, sort order, description, venue name, venue address, venue URL (:135-141). Each one constructs the sibling fragment closed over T and hands it the matching interface property. FluentValidation's Include merges the target validator's rules into this one rather than nesting them, so the failures surface with their own property paths and a caller sees one flat result.
  • Why it's built this way: the class doc (ActivityValidationRules.cs:123-128) states the contract: these are the rules the create and the update request share, declared once, with each concrete validator including this and adding only the rules its own operation needs. Today there is exactly one such delta, the create-only ActivityEventIdRules<T>.
  • Where it's used: included by ActivityCreateRequestValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:11, followed by the create-only event-id fragment at :15) and by ActivityUpdateRequestValidator, whose entire body is one expression-bodied Include of this fragment (.../Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:10). Both validators are registered by the convention scan in DependencyInjection (DependencyInjection.cs:133) and reached through the framework's command-to-request validator bridge, which the AddEntityCrud call for Activity completes (DependencyInjection.cs:144).

DependencyInjection

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:49 · Level 11 · class (static, extension block)

  • What it is: the Conference module's application-layer composition root: a static class exposing AddModuleConferenceApplication(ApplicationSettings), which registers every application service this module needs into the DI container.
  • Depends on: ApplicationSettings; the Conference domain aggregates and children (Event, Session, Speaker, Category, CategoryItem, Question, Activity, Sponsor, Room, EventSpeaker, EventQuestionAnswer, SessionSpeaker, SessionCategoryItem, SessionQuestionAnswer, SpeakerCategoryItem, SpeakerQuestionAnswer); the framework generics EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, IEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, INavigationPopulator<in TEntity>, NullNavigationPopulator<TEntity>, DeleteEntityCommand<TEntity, TIdentifierType> and DeleteEntityHandler<TEntity, TIdentifierType>; the cross-module ports ISessionBookmarkValidationService and IEventLiveValidationService; the create/update request records the CRUD registrations close over (ConferenceCategoryCreateRequest, ConferenceCategoryUpdateRequest, ActivityCreateRequest, ActivityUpdateRequest, SponsorCreateRequest, SponsorUpdateRequest, SpeakerUpdateRequest) plus UpdateSpeakerCommand; ClassReference; and Microsoft.Extensions.DependencyInjection with its Extensions namespace (the TryAdd* helpers).
  • Concept, the module composition root written as an extension(IServiceCollection) block. [Rubric §5, Vertical Slice] assesses whether each module wires its own slice rather than a central registry knowing about every type; [Rubric §2, Design Patterns] assesses idiomatic registration. The registration method lives inside a C# extension(IServiceCollection services) block (DependencyInjection.cs:51), so callers write services.AddModuleConferenceApplication(settings): the same extension(T) member style used for DI across the codebase, explained once in the primer. The class comment (DependencyInjection.cs:44-48) names the deliberate split this file embodies: explicit registrations for the generic per-entity services (which cannot be discovered by convention, because the closed generic has to be spelled out) and Scrutor assembly scanning for everything hand-written (handlers, mappers, validators), so adding a use case needs no edit here.
  • Walkthrough (in body order):
    • _ = applicationSettings (DependencyInjection.cs:55): the settings object is part of the module registration contract but this module does not branch on it today; the discard plus the inline comment "Reserved for future use (e.g., profiler decorators)" is what keeps the unused-parameter analyzer quiet without dropping the parameter from the signature.
    • Domain service (:55): IEventCascadeDeletionDomainService to EventCascadeDeletionDomainService as a singleton. It is stateless, which is why singleton is safe.
    • Session scoring queue (:57-62): SessionScoringQueue is registered concretely (:61) and behind ISessionScoringQueue, with the interface registration written as a factory that resolves the concrete singleton (sp => sp.GetRequiredService<SessionScoringQueue>(), :62) rather than as a second TryAddSingleton<ISessionScoringQueue, SessionScoringQueue>(). The comment above it (:57-60) states why: long-running AI scoring runs off the request path, the hosted drain in Infrastructure needs the reader side and the completion callback, and both registrations must resolve to the ONE instance, or producers would write to a queue nobody drains. This is the classic two-registrations-one-instance trap, and the factory form is the fix.
    • Aggregate roots with custom navigation populators (:64-79): Event (:65-67), Session (:69-71), Speaker (:73-75) and Category (:77-79) each get three scoped registrations, an INavigationPopulator<T> (their bespoke populators EventNavigationPopulator, SessionNavigationPopulator, SpeakerNavigationPopulator, ConferenceCategoryNavigationPopulator), an IEntityQueryService<T, TDTO, TId>, and a delete-command handler. Four of the twelve lines deviate from the generic default, and the deviations are the interesting part: Event binds its delete to the bespoke DeleteEventHandler (:67) because deleting an event has to cascade, Session binds its delete to DeleteSessionHandler (:71), Speaker binds its query service to SpeakerEntityQueryService (:74), and Category binds its delete to DeleteConferenceCategoryHandler (:79). Everything else uses the framework generics unchanged.
    • Aggregate roots with no navigation properties at all (:81-84): Question is the only member of this bucket, and it is the only entity in the whole file registered with NullNavigationPopulator<Question> (:82), the do-nothing populator that satisfies the contract when there is nothing to eager-load, plus the generic EntityQueryService and DeleteEntityHandler.
    • Aggregate roots whose only navigation is the parent Event FK reference (:86-91): Activity (:87-88) and Sponsor (:90-91) each get a bespoke populator (ActivityNavigationPopulator, SponsorNavigationPopulator) that resolves just that back-reference, plus the generic query service. Neither gets a delete handler here: both take theirs from the AddEntityCrud calls further down.
    • Child entities (:93-120): Room, CategoryItem, EventSpeaker, EventQuestionAnswer, SessionSpeaker, SessionCategoryItem, SessionQuestionAnswer and SpeakerCategoryItem each get their own FK populator (RoomNavigationPopulator, CategoryItemNavigationPopulator, EventSpeakerNavigationPopulator, EventQuestionAnswerNavigationPopulator, SessionSpeakerNavigationPopulator, SessionCategoryItemNavigationPopulator, SessionQuestionAnswerNavigationPopulator, SpeakerCategoryItemNavigationPopulator) plus the base EntityQueryService, and deliberately no delete handler: children are removed through their aggregate root, never addressed directly by a delete command. SpeakerQuestionAnswer is the one asymmetry, it gets SpeakerQuestionAnswerNavigationPopulator (:120) but no query service, and the comment above it (:118-119) says so outright: it has no query service today, and registering the populator future-proofs the one that would be added alongside it.
    • Cross-module ports (:122-126): ISessionBookmarkValidationService to SessionBookmarkValidationService (:123) and IEventLiveValidationService to EventLiveValidationService (:126), the in-process interfaces the Engagement module consumes (the file's comments name Engagement and its live layer as the consumers).
    • Convention scan (:130): services.ScanModuleApplicationServices<ClassReference>() sweeps this assembly for, per the comment on :128-129, domain event handlers, DTO/request mappers, command/query handlers, and validators. The framework method behind it (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:163-165, forwarding to the Assembly overload at :179) first calls ThrowIfPipelineSealed (:182), which fails loudly if AddApplicationDecorators() already ran on this collection, because handlers registered after that point would never be wrapped by the decorator pipeline. It then runs a series of Scrutor services.Scan(...) passes: singleton lifetimes for domain and integration event handlers (:185-196), scoped for mappers and the rest.
    • Generic write side for the plain-CRUD aggregates (:132-142): one AddEntityCrud<...>() call each for Category (:140), Activity (:141) and Sponsor (:142), closed over the aggregate, its DTO, its identifier type, and its create and update request records. The framework method (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:331-356) registers a generic create handler, a generic update handler and a generic delete handler, all with TryAdd, and then bridges the update command to its request validator with AddCommandRequestValidator (:349-351), which is the call that lets ActivityFieldRules<T> run for an update command the scan never sees. The long comment above the calls (:132-139) explains the placement: these run after the scan on purpose, so the module's own CreateXHandler (already registered, and the only one of the three that still carries module vocabulary in a log line) keeps the create verb, while the update and delete verbs, which nothing in this assembly registers, come from the framework.
    • Derived-command update for Speaker (:144-150): AddEntityUpdate<UpdateSpeakerCommand, Speaker, SpeakerDTO, SpeakerIdentifierType, SpeakerUpdateRequest>() (:150). The comment (:144-149) gives the reason Speaker cannot use the plain CRUD call: its update carries one piece of state the request body must never hold (BR-214's CallerIsOrganizer), so it rides the derived-command path, UpdateSpeakerCommand plus the command-aware SpeakerUpdateApplier the scan picked up. The framework method (.../MMCA.Common.Application/DependencyInjection.cs:442-457) TryAdds the generic command handler and registers the validator bridge, so the module's own UpdateSpeakerHandler keeps the handler slot and the bridge still completes.
    • The method then returns services (:152) for fluent chaining.
  • Why it's built this way: every registration uses TryAdd* rather than Add*, so a host (or a test) can register its own implementation first and this method will not clobber it or produce a duplicate registration; the same TryAdd semantics are what let the CRUD and update helpers sit after the scan and fill only the gaps it left. Splitting explicit generics from convention scanning keeps a file that wires roughly twenty entities inside 110 lines of body while still registering the module's dozens of hand-written handlers. Registering the cross-module validation services here as ordinary in-process interfaces is exactly what lets the same module code run co-located or split behind gRPC without a rewrite (ADR-007, ADR-008); the per-entity INavigationPopulator registrations are the populator pattern of ADR-002 being bound one entity at a time.
  • Where it's used: called by the Conference module's API-layer registration (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:25), which is itself invoked through the module's IModule implementation during host startup; modules are discovered and registered in topological order by the ModuleLoader.
  • Caveats / not in source: applicationSettings is accepted and immediately discarded; the "profiler decorators" the comment reserves it for do not exist in this layer today. ISessionizeService is absent from this file on purpose: the Application layer owns that port, but its typed-client registration lives in Conference Infrastructure. The ordering constraint the two ThrowIfPipelineSealed guards enforce (this method must run before AddApplicationDecorators()) is checked at registration time, not expressed in the type system, so a host that calls them in the wrong order learns it from an exception rather than from the compiler.

GetPublicActivityFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.GetPublicActivityFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:16 · Level 11 · class (sealed)

  • What it is: the query handler that answers GetPublicActivityFilterQuery. It resolves the ids of the published events and returns an Activity.EventId IN (...) specification the read pipeline can AND into any activity query.
  • Depends on: IUnitOfWork (constructor-injected, GetPublicActivityFilterHandler.cs:17), PublicConferenceVisibility, Specification<TEntity, TIdentifierType> and InlineSpecification<TEntity, TIdentifierType>, IQueryHandler<in TQuery, TResult>, Result, and the Activity aggregate it filters.
  • Concept, the handler that returns a specification instead of rows. [Rubric §6, CQRS & Event-Driven] assesses whether read intent is modelled as first-class, individually testable use cases; [Rubric §11, Security] assesses whether the visibility rule is applied server-side at the data boundary. Combining the two produces the shape here: the handler's TResult is not a DTO or a page but Result<Specification<Activity, ActivityIdentifierType>> (:18). The caller receives a composable predicate and hands it to the generic entity-query layer, which applies it before paging and sorting, so the rule cannot be defeated by a crafted query string. [Rubric §12, Performance & Scalability] is served by the same choice: the filter arrives as one translated IN clause on a column, not as an in-memory post-filter over a full result set.
  • Walkthrough: a primary-constructor class taking IUnitOfWork unitOfWork (:16-17), with one method.
    • HandleAsync(GetPublicActivityFilterQuery query, CancellationToken cancellationToken = default) (:21-23). The query parameter is unused by design, the query record has no fields.
    • await PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:25-27) does the work. That shared helper resolves the read repository for Event (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:40), projects e => e.Id under the predicate e => e.IsPublished with asTracking: false (PublicConferenceVisibility.cs:42-44), and materializes the sequence once so the caller embeds a stable collection EF can translate into IN (PublicConferenceVisibility.cs:46-47). Every await in this path is .ConfigureAwait(false), the codebase convention for library code.
    • The return (:29-30) wraps a new InlineSpecification<Activity, ActivityIdentifierType>(a => publishedEventIds.Contains(a.EventId)) in Result.Success<...>. InlineSpecification is the lambda-carrying specification, so no bespoke specification class is needed for a one-line criteria. The handler has no failure path: an empty published-event list is a valid answer that yields a specification matching nothing.
  • Why it's built this way: the class doc (:10-15) states the alignment: the id-list shape mirrors the sponsor, speaker and session public filters, so no navigation join is required and the criteria stays translatable on any engine (ADR-018). Centralizing the id resolution in PublicConferenceVisibility rather than repeating the IsPublished projection in every public-filter handler means the definition of "published" changes in one place.
  • Where it's used: injected into ActivitiesController as IQueryHandler<GetPublicActivityFilterQuery, Result<Specification<Activity, ActivityIdentifierType>>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Activities/ActivitiesController.cs:45) and called from the controller's GetReadSpecificationAsync override (ActivitiesController.cs:69-79), the framework read hook, which short-circuits to null for privileged readers (:71-72, guarded by IsPrivileged at :52) so Organizers and ContentEditors keep seeing activities of events still being assembled. Because the hook is the base controller's, every read action (list, paged, lookup, by-id) is scoped from that one place, and an activity the filter excludes is a 404 rather than a redacted record (:60-65). The handler is registered by convention, not explicitly: the scan in DependencyInjection (DependencyInjection.cs:133) picks up every IQueryHandler<,> in the assembly.
  • Caveats / not in source: the controller maps a failed Result to null (ActivitiesController.cs:78), that is, to no filter at all, the fail-open shape the sibling public-filter handlers share; no path in this handler produces a failure today. The two-query shape (published event ids, then activities) is also two round trips by construction, and nothing in the handler or in PublicConferenceVisibility memoizes the id list, so each call re-reads it.

ConferenceCategoryUpdateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequest.cs:4 · Level 0 · record

  • What it is: the request body of PUT /conferencecategories/{id}, three properties describing the new state of a Category: a Title, a Sort order, and an optional Type.
  • Depends on: nothing first-party and nothing external. It is a plain payload record with no domain types, no base class, and no marker interface (ConferenceCategoryUpdateRequest.cs:1-14 is the whole file).
  • Concept introduced: the update request as payload only. [Rubric §9, API & Contract Design] assesses whether the contract crossing the wire says exactly what the caller controls and nothing more. An update needs three things: which row, what the caller last saw, and what to write. Only the third is in this record. The row id arrives on the route, and the concurrency token arrives in the If-Match header (ADR-035), pulled out by SupportsIfMatchAttribute.RequiredToken(HttpContext) in the action (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:124). The framework's UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType> marries the three into one dispatchable object (ConferenceCategoriesController.cs:127). [Rubric §11, Security]: a token that lives in the header rather than the body cannot be dropped by a caller who simply omits a property, because a conditional write with no precondition never reaches a handler at all (428 Precondition Required, declared at ConferenceCategoriesController.cs:118).
  • Walkthrough: public record class ConferenceCategoryUpdateRequest (:4, deliberately not sealed, though nothing derives from it today) with three init members. Title is required string (:7), so the record cannot be constructed without one and a JSON body that omits it fails model binding before any validator runs. Sort is a plain int (:10) that defaults to 0. Type is string? (:13), documented as an optional discriminator such as "session" or "speaker".
  • Why it's built this way: the three members line up one for one with the aggregate's guarded mutation, Category.Update(string title, int sort, string? type) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:84), which is what lets ConferenceCategoryUpdateApplier be four lines of delegation with no field-by-field copying. Keeping the record free of framework markers is what lets one generic command and one generic handler serve it, with the mapping explicit rather than reflective (ADR-001).
  • Where it's used: bound from the body by ConferenceCategoriesController's UpdateAsync (ConferenceCategoriesController.cs:119-135); named as the TUpdateRequest argument of AddEntityCrud (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:143), which is what closes the framework's update handler and validator bridge over it; validated by ConferenceCategoryUpdateRequestValidator; applied to the aggregate by ConferenceCategoryUpdateApplier. Its create-side sibling is ConferenceCategoryCreateRequest, which needs no separate command because a create carries its identity in the body.

GetPublicEventSpeakerFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10 · Level 0 · record (sealed)

  • What it is: a parameterless marker query asking for the filter that limits EventSpeaker junction rows to the ones a non-privileged caller may read. The whole type is one line: public sealed record GetPublicEventSpeakerFilterQuery; (GetPublicEventSpeakerFilterQuery.cs:10).
  • Depends on: nothing first-party, nothing external. It is an empty record with no positional parameters.
  • Concept: none new. The marker-query shape is taught under GetPublicSessionFilterQuery, and the visibility rules themselves are defined once in PublicConferenceVisibility. What this query adds is a junction with two parents. The doc comment (GetPublicEventSpeakerFilterQuery.cs:3-9) states both legs and the leak each one closes: a row is readable only when its parent event is published (BR-108), because otherwise the join endpoints would list the speakers of an unannounced event and reveal that it exists, AND when its parent speaker is publicly visible (BR-239), because otherwise the association endpoint would hand back the whole Sessionize-imported roster that the speaker list itself hides. [Rubric §11, Security] assesses whether an anonymous surface can be used to infer the existence of content the caller may not read; a join row with two parents can leak through either of them.
  • Walkthrough: no members. Every line of behavior lives in GetPublicEventSpeakerFilterHandler.
  • Why it's built this way: the rules belong to the two parents, not to the join row, so the query carries no arguments and the handler derives its answer from the shared resolver instead of restating either rule.
  • Where it's used: handled by GetPublicEventSpeakerFilterHandler; injected into EventSpeakersController as an IQueryHandler<in TQuery, TResult> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:51) and constructed inside that controller's override of the framework read hook GetReadSpecificationAsync (EventSpeakersController.cs:78), which returns null for privileged readers (:75-76) and the specification for everyone else. Because the hook is a single override, all four [AllowAnonymous] reads inherit the filter without restating it: the unpaged list (:84-92), the paged list (:94-107), the lookup (:113-119), and the by-id read (:121-130).

SessionizeCategoryItem

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:60 · Level 0 · record (sealed)

  • What it is: the leaf DTO for one category value from the Sessionize "View All" API (for example "Beginner" under the "Level" category, or ".NET" under "Track"): an Id, a Name, and a Sort order.
  • Depends on: System.Text.Json.Serialization ([JsonPropertyName], BCL) only.
  • Concept introduced, the external-API contract DTO. [Rubric §9, API & Contract Design] assesses whether contracts crossing a boundary are explicit; [Rubric §32, Dependency & Supply-Chain] assesses controlling the shape of data arriving from a third party. The whole Sessionize* family in this one file models the JSON wire format of the external system the conference agenda is imported from. Every member of the family follows the same three rules: it is a sealed record, every property is init-only, and every property carries a [JsonPropertyName("...")] mapping the C# name onto the exact Sessionize field (SessionizeModels.cs:62-69). Reference-typed properties get a non-null default (Name { get; init; } = string.Empty at line 66, collections = []), so a payload missing a field deserializes to an empty value rather than a null the import code would have to guard. Modeling the external contract as its own dedicated immutable type, instead of binding straight onto domain entities, is the anti-corruption discipline: the outside shape is captured here and translated into the domain by the sync strategies. The remaining Sessionize* sections cross-reference back to this one rather than repeating the shape.
  • Walkthrough: three init properties, Id (int, line 63), Name (string, empty default, line 66), Sort (int, line 69), each JSON-mapped by the attribute on the line above it. No behavior at all; it is a pure data-transfer record.
  • Why it's built this way: record gives structural equality and a compact declaration (the same reasoning as the ValueObject discussion), and init plus non-null defaults means System.Text.Json can populate it while callers can never mutate it afterward.
  • Where it's used: nested inside SessionizeCategory's Items (SessionizeModels.cs:56); consumed by the category import path, CategorySyncStrategy.

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:126 · Level 0 · record (sealed)

  • What it is: the DTO for one speaker social link from Sessionize: a Title, a Url, and a LinkType (for example "Twitter" or "LinkedIn").
  • Depends on: System.Text.Json.Serialization (BCL) only.
  • Concept: an external-API contract DTO, the pattern taught on SessionizeCategoryItem. [Rubric §9, API & Contract Design].
  • Walkthrough: three init string properties, all empty-defaulted and JSON-mapped: Title (line 129), Url (line 132), LinkType (line 135).
  • Why it's built this way: Url is a plain string, not a Uri. Keeping it a string means a non-canonical value from Sessionize cannot fail deserialization at the wire boundary; any parsing or validation happens later, in the import path, where a bad value can be reported as a warning instead of an exception.
  • Where it's used: nested inside SessionizeSpeaker's Links collection (SessionizeModels.cs:110), read by SpeakerSyncStrategy.

SessionizeQuestion

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:25 · Level 0 · record (sealed)

  • What it is: the DTO for one custom-question definition from Sessionize (the question itself, not an answer to it): Id, Question text, QuestionType, and a Sort order.
  • Depends on: System.Text.Json.Serialization (BCL) only.
  • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
  • Walkthrough: four init properties, Id (int, line 28), Question (string, empty default, line 31), QuestionType (string, empty default, line 34), Sort (int, line 37). QuestionType arrives as a free-form string, so the import decides how to interpret it rather than the wire model constraining it to an enum.
  • Where it's used: nested inside SessionizeResponse's Questions (SessionizeModels.cs:21); imported by QuestionSyncStrategy. Its answers are carried separately, by SessionizeQuestionAnswer.

SessionizeQuestionAnswer

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:192 · Level 0 · record (sealed)

  • What it is: the DTO for one answer to a Sessionize custom question: the QuestionId it answers and its AnswerValue.
  • Depends on: System.Text.Json.Serialization (BCL) only.
  • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
  • Walkthrough: two init properties, QuestionId (int, line 195) and AnswerValue (string, empty default, line 198). The answer points back at its question by id rather than nesting the question definition, which is why the same answer record can hang off two different parents.
  • Where it's used: nested inside both SessionizeSpeaker's (SessionizeModels.cs:122) and SessionizeSession's (SessionizeModels.cs:170) QuestionAnswers collections.

SessionizeRoom

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:73 · Level 0 · record (sealed)

  • What it is: the DTO for one room from Sessionize: Id, Name, Sort.
  • Depends on: System.Text.Json.Serialization (BCL) only.
  • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
  • Walkthrough: three init properties, Id (int, line 76), Name (string, empty default, line 79), Sort (int, line 82). Structurally identical to SessionizeCategoryItem; the two are kept as distinct types (rather than one shared "named thing" record) so a change on either side of the Sessionize contract cannot silently propagate to the other import path.
  • Where it's used: nested inside SessionizeResponse's Rooms (SessionizeModels.cs:12); imported by RoomSyncStrategy into the domain Room. Sessions reference a room by RoomId (SessionizeModels.cs:173), not by nesting this record.

SessionizeCategory

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:41 · Level 1 · record (sealed)

  • What it is: the DTO for one Sessionize category (for example "Level" or "Track"), owning the nested list of its SessionizeCategoryItem values.
  • Depends on: SessionizeCategoryItem (its Items collection); System.Text.Json.Serialization (BCL).
  • Concept: an external-API contract DTO (SessionizeCategoryItem). This is the first Sessionize* record that nests another, which is exactly why it sits one dependency level up. [Rubric §9, API & Contract Design].
  • Walkthrough: five init properties. Id (int, line 44), Title (string, empty default, line 47), and Sort (int, line 50) are the flat fields; Type is string? (line 53), so an absent JSON field stays null rather than being flattened to an empty string; Items is IReadOnlyList<SessionizeCategoryItem> defaulted to the collection expression [] (line 56), so a category with no values deserializes to an empty list. Exposing the collection as IReadOnlyList<T> (not List<T>) keeps the record immutable in practice as well as by init.
  • Where it's used: nested inside SessionizeResponse's Categories (SessionizeModels.cs:9); both the category and its items are reconciled against the domain Category by CategorySyncStrategy.

SessionizeSession

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:139 · Level 1 · record (sealed)

  • What it is: the richest Sessionize DTO: one conference session with its schedule, room, speaker references, category assignments, question answers, and live/recording metadata.
  • Depends on: SessionizeQuestionAnswer (its QuestionAnswers list, line 170); System.Text.Json.Serialization (BCL).
  • Concept: an external-API contract DTO (SessionizeCategoryItem), here at full width. [Rubric §9, API & Contract Design].
  • Walkthrough: sixteen init properties (SessionizeModels.cs:141-188). Five groups are worth knowing:
    1. Id (int, line 143) is the only property in the whole file annotated [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] (line 142). Sessionize sometimes serializes a session id as a JSON string rather than a number, and that one attribute is what stops the whole import from failing on it.
    2. StartsAt and EndsAt are DateTime? (lines 152 and 155), so an unscheduled session round-trips with nulls instead of a deserialization error. SessionSyncStrategy validates the pair rather than the wire model doing it: ValidateSessionTimes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:53) warns when a start falls before the event's start date and when an end falls after its end date (BR-86, :55-64), and warns on a zero or negative duration while storing the value as-is per BR-122 (:66-70).
    3. Speakers is IReadOnlyList<Guid> (line 164), CategoryItems is IReadOnlyList<int> (line 167), and RoomId is int? (line 173): the session references other entities by their Sessionize ids instead of nesting the full objects, so those cross-references are resolved during import against the already-imported speakers, category items, and rooms.
    4. Description, LiveUrl, RecordingUrl, and Status are nullable strings (lines 149, 176, 179, 182); LiveUrl and RecordingUrl stay string for the same reason as SessionizeLink's Url. Title (line 146) is the one non-nullable string, empty-defaulted.
    5. Four booleans classify the session: IsServiceSession (line 158) and IsPlenumSession (line 161) mark non-talk and plenary slots, IsInformed (line 185) and IsConfirmed (line 188) carry the speaker-communication state Sessionize tracks.
  • Why it's built this way: the id-reference lists mirror how Sessionize normalizes its own payload; keeping the DTO faithful to that shape (rather than pre-joining it) means the wire model stays a mechanical translation and every judgement call lives in the sync strategies, where it can emit a warning.
  • Where it's used: nested inside SessionizeResponse's Sessions (SessionizeModels.cs:18); imported by SessionSyncStrategy under RefreshFromSessionizeHandler.

SessionizeSpeaker

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:86 · Level 1 · record (sealed)

  • What it is: the DTO for one speaker from Sessionize, with profile fields, social SessionizeLinks, question answers, and id references to the speaker's sessions and category items.
  • Depends on: SessionizeLink (Links), SessionizeQuestionAnswer (QuestionAnswers); System.Text.Json.Serialization (BCL).
  • Concept: an external-API contract DTO (SessionizeCategoryItem). [Rubric §9, API & Contract Design].
  • Walkthrough: twelve init properties (SessionizeModels.cs:88-122). Id is a Guid (line 89), unlike the int ids of every other Sessionize entity in this file. The optional profile fields Bio (line 98), TagLine (line 101), ProfilePicture (line 104), and FullName (line 116) are nullable strings, while FirstName (line 92) and LastName (line 95) are empty-defaulted non-nullable ones. IsTopSpeaker is a bool (line 107). Four collections, Links (line 110), Sessions (IReadOnlyList<int>, line 113), CategoryItems (IReadOnlyList<int>, line 119), and QuestionAnswers (line 122), are all IReadOnlyList<T> defaulted to [].
  • Why it's built this way: the Guid speaker id lines up with the Conference module's SpeakerIdentifierType = Guid alias, so the import can carry a Sessionize speaker id straight into a domain Speaker key without a conversion or a lookup table. That both FullName and the FirstName/LastName pair exist is Sessionize's redundancy, not the module's: the wire model keeps both and lets SpeakerSyncStrategy choose.
  • Where it's used: nested inside SessionizeResponse's Speakers (SessionizeModels.cs:15); imported by SpeakerSyncStrategy, which takes the record directly as a parameter (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:101-102). One consequence of that import is load-bearing elsewhere in this chapter: every synced speaker without an active link gets an EventSpeaker row (SpeakerSyncStrategy.cs:59), which is why GetPublicEventSpeakerFilterHandler cannot treat that junction as an acceptance signal.

SessionizeResponse

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/SessionizeModels.cs:6 · Level 2 · record (sealed)

  • What it is: the top-level envelope for the Sessionize "View All" API response, holding the five parallel collections (Categories, Rooms, Speakers, Sessions, Questions) that make up an entire conference import payload.
  • Depends on: SessionizeCategory, SessionizeRoom, SessionizeSpeaker, SessionizeSession, SessionizeQuestion; System.Text.Json.Serialization (BCL).
  • Concept: the root of the external-API contract DTO tree that SessionizeCategoryItem taught. [Rubric §9, API & Contract Design]. This is the object ISessionizeService returns and the single input every Sessionize sync strategy reads from.
  • Walkthrough: five init IReadOnlyList<...> properties, each defaulted to [] and JSON-mapped to the lower-cased Sessionize field name: Categories (line 9), Rooms (line 12), Speakers (line 15), Sessions (line 18), Questions (line 21). "View All" is Sessionize's denormalized endpoint: it returns every entity kind in one document, which is why this envelope has one collection per kind rather than a paged, per-type shape.
  • Why it's built this way: one immutable envelope makes the import easy to reason about, the strategies receive the whole snapshot at once and reconcile the domain against it; and the empty-list defaults mean a payload missing a section is still a valid, non-null response the strategies can iterate over without null checks.
  • Where it's used: returned (nullable) by ISessionizeService; carried to the sync strategies as the required Response property of SessionizeSyncContext (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:13), which RefreshFromSessionizeHandler builds under the RefreshFromSessionizeCommand use case.

ISessionizeService

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Sessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Sessionize/ISessionizeService.cs:6 · Level 3 · interface

  • What it is: the one-method contract for fetching a whole conference from the Sessionize "View All" API, returning a SessionizeResponse or null when the response is empty.
  • Depends on: SessionizeResponse (its return type). Nothing else, no HttpClient, no options type.
  • Concept introduced, the outbound-port interface (dependency inversion at an external boundary). [Rubric §3, Clean Architecture] assesses whether the Application layer depends only on abstractions it owns, with concrete adapters living further out; [Rubric §7, Microservices Readiness] assesses isolating third-party calls behind a swappable boundary. Here the Application layer declares what it needs from Sessionize (this interface), while the HTTP client that actually calls the API lives in Conference Infrastructure and implements it: SessionizeService, registered as a typed client with the base address https://sessionize.com/api/v2/ via services.AddHttpClient<ISessionizeService, SessionizeService>(...) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:26-28). That inversion is what keeps the import use case exercisable without a network: the integration tier substitutes FakeSessionizeService (MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Infrastructure/FakeSessionizeService.cs:12) and feeds a canned response.
  • Walkthrough: a single method, Task<SessionizeResponse?> GetAllAsync(string sessionizeCode, CancellationToken cancellationToken = default) (ISessionizeService.cs:12). sessionizeCode is the per-event Sessionize code (the XML doc gives the example "kqf8l42a", line 9); the nullable return signals an empty or absent response instead of throwing, so the caller decides whether an empty import is an error; and the defaulted trailing CancellationToken follows the codebase convention that every async boundary is cancelable.
  • Why it's built this way: a narrow, single-purpose port is the smallest surface the import needs, which makes both the real HTTP adapter and its test double trivial to write and keeps retry and timeout policy an Infrastructure concern.
  • Where it's used: constructor-injected into RefreshFromSessionizeHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:22) and called there as GetAllAsync(@event.SessionizeCode, cancellationToken) (RefreshFromSessionizeHandler.cs:82). Note that it is not registered by this layer's DependencyInjection: the Application layer owns the interface, Infrastructure owns and registers the implementation.

ConferenceCategoryUpdateApplier

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateApplier.cs:12 · Level 7 · class (sealed)

  • What it is: the four-line adapter that takes a ConferenceCategoryUpdateRequest and a loaded Category and calls the aggregate's own guarded Update method, so the framework's generic update handler never has to know a category field name.
  • Depends on: IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType> from MMCA.Common.Application.Interfaces (:2,13), the Category aggregate (:1,13), ConferenceCategoryUpdateRequest (:13), and Result from MMCA.Common.Shared.Abstractions (:3,16). ConferenceCategoryIdentifierType is the module's identifier alias.
  • Concept introduced: the update applier, the write-side twin of the create mapper. [Rubric §4, DDD] assesses whether the aggregate keeps its invariants and its events; [Rubric §1, SOLID] assesses whether a type has exactly one reason to change. The framework ships one generic update handler, UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>, which owns loading the row, applying the optimistic-concurrency token, and saving. It cannot own the mutation, because the mutation is entity-specific. The applier interface is the extension point that closes the gap: the mapper owns "request to a new aggregate", the applier owns "request onto an existing aggregate" (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:62-75). Two details of that contract are worth reading once. The applier answers with a bare Result rather than a new entity, because the instance handed in is the tracked one: a successful apply has already mutated it in place, and a refusal must leave it untouched so nothing reaches the database (IEntityDTOMapper.cs:70-74). And the applier is async-shaped (Task<Result>, IEntityDTOMapper.cs:91) even though this one has nothing to await, so that an applier that does need a lookup fits the same contract.
  • Walkthrough:
    1. The class declaration binds the three type arguments once: IEntityUpdateApplier<Category, ConferenceCategoryUpdateRequest, ConferenceCategoryIdentifierType> (:13). That closed triple is what the DI scan matches on and what closes the generic handler over this aggregate.
    2. ApplyAsync (:16) guards both arguments with ArgumentNullException.ThrowIfNull (:18-19). These are programming-error guards, not validation: a null here means the framework handed the applier something impossible, which is an exception rather than a Result failure.
    3. The body is one delegation, wrapped in Task.FromResult because there is nothing to await: entity.Update(request.Title, request.Sort, request.Type) (:21-24). Nothing in this file writes a property.
    4. Inside the aggregate, Category.Update (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:84) is where the rules live: it combines CategoryInvariants.EnsureTitleIsValid and returns the failure before touching state (Category.cs:86-89), assigns the three properties (:91-93), and raises CategoryChanged with DomainEntityState.Updated (:95). A refused title therefore never mutates the tracked entity, which is exactly the contract the interface documents.
  • Why it's built this way: the module's DependencyInjection spells out the trade explicitly (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:135-143). One AddEntityCrud<Category, ConferenceCategoryDTO, ConferenceCategoryIdentifierType, ConferenceCategoryCreateRequest, ConferenceCategoryUpdateRequest>() call registers the create, update and delete handlers closed over this aggregate's types (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:339-353), and the call is deliberately placed after the module scan because it uses TryAdd: the module's own CreateConferenceCategoryHandler keeps the create verb, while the update and delete verbs, which nothing in this assembly registers, come from the framework. The result is that a plain-CRUD aggregate costs one registration line plus this applier, and the domain keeps every invariant and every event. Compare the Speaker path in the same file (DependencyInjection.cs:146-152), which needs the command-aware applier because BR-214 carries state the request body must never hold.
  • Where it's used: picked up by the convention scan services.ScanModuleApplicationServices<ClassReference>() (DependencyInjection.cs:132), which registers every IEntityUpdateApplier<,,> implementation (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:227); resolved as a constructor dependency of the generic UpdateEntityHandler (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:50), which serves the PUT /conferencecategories/{id} action on ConferenceCategoriesController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:39 and :125-127).
  • Testing: ConferenceCategoryUpdateApplierTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/UseCases/ConferenceCategoryUpdateApplierTests.cs:19) exercises the applier the way it actually runs, composed inside a real UpdateEntityHandler over a mocked repository (:29-32) rather than in isolation. Four tests: the happy path asserting the DTO carries all three updated fields (:48), a not-found id mapping to ErrorType.NotFound (:69), a whitespace-only title that fails the domain invariant and, crucially, asserts SaveChangesAsync was never called (:87-108), and the mirror assertion that a valid request saves exactly once (:111). [Rubric §14, Testability]: the third test is the one that protects the "a refusal leaves the entity untouched" half of the applier contract, which is the half a hand-written applier is most likely to break.

ConferenceCategoryUpdateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Update · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:7 · Level 8 · class (sealed)

  • What it is: the FluentValidation validator for ConferenceCategoryUpdateRequest, run by the pipeline before the update handler executes. Its entire body is one Include.
  • Depends on: AbstractValidator<T> (FluentValidation, :1,7), ConferenceCategoryUpdateRequest (:7), and the shared ConferenceCategoryTitleRules<T> rule set from MMCA.ADC.Conference.Application.Categories.Validation (:2,10).
  • Concept: none new; this is Include composition (taught in group 06) in its smallest possible form, plus one piece of wiring worth tracing once. The validator is written against the request, but the object the pipeline dispatches is an UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>. The bridge is registered by the same AddEntityCrud call that registers the handler: a CommandRequestValidator<TCommand, TRequest> closed over the command and this request (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:351-353, rationale at :308-317). The command is a closed generic built at registration time, so the module scan's reflection bridge cannot see it, which is why the framework registers it explicitly and why TryAdd still lets a hand-written command validator win. [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are single-sourced and consistently applied; [Rubric §1, SOLID]: this class's only job is composition, so a title-rule change never has to be found in two files.
  • Walkthrough: sealed class ConferenceCategoryUpdateRequestValidator : AbstractValidator<ConferenceCategoryUpdateRequest> (:7), with an expression-bodied constructor (:9-10) folding in a single parameterized rule set: Include(new ConferenceCategoryTitleRules<ConferenceCategoryUpdateRequest>(p => p.Title)). The rule set itself (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:14-21) attaches two rules with stable error codes: NotEmpty() under Category.Title.Required (:19) and MaximumLength(CategoryInvariants.TitleMaxLength) under Category.Title.MaxLength (:20). That max length is not a literal: CategoryInvariants reads it from ConferenceCategoryDTO.TitleMaxLength (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:18), which the EF configuration and the Blazor input caps also bind to, so the number is declared once and enforced at every layer.
  • Why it's built this way: Sort and Type carry no rules here, and that is a decision rather than a gap. Sort has no field-level business constraint on a category (its child items do get one, through CategoryItemSortRules<T> over the framework's NonNegativeIntRules<T>), and Type is a free-form optional discriminator. The title check is deliberately duplicated between here and the aggregate: this validator gives the caller a 400 with a stable error code before the transaction opens, while CategoryInvariants.EnsureTitleIsValid inside Category.Update is the invariant that holds even when the aggregate is mutated from a path that never saw a request. [Rubric §3, Clean Architecture] assesses whether each layer holds the checks it is entitled to hold.
  • Where it's used: registered by the module scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133) as IValidator<ConferenceCategoryUpdateRequest>, then reached through the command bridge above whenever PUT /conferencecategories/{id} dispatches (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:126-128). The same ConferenceCategoryTitleRules<T> object is included by the create-side ConferenceCategoryCreateRequestValidator against a different request type, which is the whole point of parameterizing the rule set by T.
  • Testing: ConferenceCategoryUpdateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/Validation/ConferenceCategoryUpdateRequestValidatorTests.cs:6), two tests using FluentValidation's TestValidateAsync helper: a valid request produces no errors (:20), and a request built with with { Title = string.Empty } produces an error on Title (:31). The max-length branch is not covered here.

GetPublicEventSpeakerFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicEventSpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:22 · Level 11 · class (sealed)

  • What it is: the handler for GetPublicEventSpeakerFilterQuery. It asks PublicConferenceVisibility twice, once for the published event ids and once for the visible speaker ids, and returns an EventSpeaker.EventId IN (...) AND EventSpeaker.SpeakerId IN (...) specification.
  • Depends on: IUnitOfWork (:23, injected only to hand on to the resolver), PublicConferenceVisibility, InlineSpecification<TEntity, TIdentifierType> and its base Specification<TEntity, TIdentifierType>, EventSpeaker, and Result. It implements IQueryHandler<in TQuery, TResult> to Result<Specification<EventSpeaker, EventSpeakerIdentifierType>> (:24).
  • Concept introduced: the two-parent junction filter. [Rubric §11, Security]. The other public filters in this family each derive from a single parent: GetPublicRoomFilterHandler follows the event, GetPublicSpeakerFilterHandler follows the speaker's eligible sessions. This one ANDs two independent legs, and the remarks explain why the second is not redundant (:16-21): the Sessionize import writes an EventSpeaker row for every speaker in the response, which you can read in SpeakerSyncStrategy itself, where every synced speaker without an active link gets context.Event.AddEventSpeaker(null, ss.Id) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:59). An event-only filter would therefore republish the entire imported roster through the association endpoint, which is exactly what the public speaker list hides.
  • Concept: the duplicate scalar read, taken deliberately. [Rubric §12, Performance & Scalability]. The inline comment (:35-37) records a cost decision rather than an oversight. The junction read carries no event context, so the speaker rule spans every published event; both resolver calls read the Event table, described there as bounded at single-digit rows; and the duplicate scalar read was judged cheaper than threading the already-resolved ids through the shared resolver's signature. The trade-off is in the open: one extra projection query per request in exchange for keeping PublicConferenceVisibility's API narrow.
  • Walkthrough:
    1. Resolve the published event ids: PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:31-33). Inside the resolver that is one scalar projection of Event.Id filtered by IsPublished, read untracked (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:42-44), then materialized once so the caller embeds a stable collection EF can translate to IN (PublicConferenceVisibility.cs:46-47).
    2. Resolve the visible speaker ids: GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken) (:38-40), with the optional event scope left at its default and spelled out with a named argument so it reads as a decision rather than an omission. Inside, that is the BR-239 chain: the published events (PublicConferenceVisibility.cs:109), the optional narrowing to one scoped event (:113-117), an empty answer when the scope is empty (:119-120), the eligible sessions inside that scope (:122-124), then the SessionSpeaker join projected down to distinct speaker ids (:126-133).
    3. Wrap es => eventIds.Contains(es.EventId) && speakerIds.Contains(es.SpeakerId) in an InlineSpecification<TEntity, TIdentifierType> and return Result.Success (:42-44). There is no failure path: the handler cannot fail on its own terms.
  • Why it's built this way: the summary states the shape (:10-15) and the remarks give the reason for the second leg (:16-21). Both legs are id lists turned into Contains, never navigation joins, so the criteria stays translatable on any provider (ADR-018), and deriving both from the shared resolver means the junction cannot drift away from the entities whose visibility it follows. The resolver's own remarks say why the EventSpeaker join is never read back as a visibility grant (PublicConferenceVisibility.cs:97-103).
  • Where it's used: EventSpeakersController injects it as an IQueryHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:51) and calls it from one place, its override of the framework read hook GetReadSpecificationAsync (:72-82), which short-circuits to null for privileged readers (:75-76, backed by currentUserService.IsPrivilegedConferenceReader() at :58). Because the hook is inherited by every read action, the four [AllowAnonymous] reads below it are attribute-only passthroughs with empty bodies (:84-92, :94-107, :113-119, :121-130), and the doc comment says so (:65-70). All four also sit behind [OutputCache(PolicyName = "EventsCache")]. Note that the class-level [HasPermission(ConferencePermissions.EventsManage)] (:46) is what those four actions override, which is exactly why the handler has to carry the visibility rules itself. The CSV export is handled differently: rather than filter it, the controller forbids it outright for non-privileged callers (:139-153, rationale at :132-137), because the inherited export streams with no specification.
  • Testing: GetPublicEventSpeakerFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandlerTests.cs:19), six tests on the shared HandlerTestBase<THandler>: the success shape (:74), a row whose event is published and whose speaker is visible (:83), a row on an unpublished event (:94), a row of a hidden speaker on a published event (:105), a world where no speaker is visible (:118), and one that captures the predicate the handler hands to the Event projection, compiles it, and asserts it accepts a published event and rejects an unpublished one (:128). One fixture detail is a property of the entity rather than of the test: EventSpeaker.EventId is get-only and written by EF, so a row built through the factory in memory carries the default id, and the fixture uses default as its row event id (:21-22). [Rubric §14, Testability]: the speaker leg is the rule most likely to be dropped as redundant, and :105 is the test that would catch it.
  • Caveats / not-in-source: the controller maps a failed Result to null, meaning no filter: return result.IsSuccess ? result.Value : null; (EventSpeakersController.cs:81), which would widen the read rather than narrow it. Nothing in this handler can produce that failure today, so the exposure is latent rather than live, but it is the opposite of the fail-closed default the rest of the visibility code takes, and the same shape appears on the room controller. Both id lists are also materialized into the predicate, so the two IN lists grow with the number of published events and of publicly visible speakers; nothing in this file bounds either.

GetPublicRoomFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterQuery.cs:14 · Level 0 · record (sealed)

  • What it is: a parameterless marker query asking for the filter that limits Room rows to the ones a non-privileged caller may read: a room is publicly visible when the event it belongs to is published (BR-108). The whole type is one line, public sealed record GetPublicRoomFilterQuery(); (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterQuery.cs:14).
  • Depends on: nothing first-party, nothing external.
  • Concept: none new. The marker-query shape is taught under GetPublicSessionFilterQuery; the rule it names is resolved once in PublicConferenceVisibility. [Rubric §11, Security] assesses whether an anonymous surface leaks the existence or the detail of content the caller may not read. The doc comment names the concrete exposure this query closes (GetPublicRoomFilterQuery.cs:3-8): rooms of an unpublished event stay hidden, so an event still being assembled does not publish its floor plan, room names, or capacities before the agenda is announced.
  • Walkthrough: no members. Note the declaration-style difference from its sibling in this same group: this one is written with an empty parameter list, GetPublicRoomFilterQuery(), which declares a primary constructor, while GetPublicEventSpeakerFilterQuery is written without one, GetPublicEventSpeakerFilterQuery; (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterQuery.cs:10). Both are constructed identically at the call site as new X(), so the difference is cosmetic; it is worth knowing only so you do not read meaning into it.
  • Why it's built this way: the remarks (GetPublicRoomFilterQuery.cs:9-13) record the design choice behind the shape of the answer. Room carries a real EventId column (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:38), so the rule resolves to a published-event id list and comes back as a Room.EventId IN (...) criteria. No navigation join is involved, which keeps the criteria engine-portable (ADR-018) and keeps Room's reference to Event a by-id boundary rather than a traversal.
  • Where it's used: handled by GetPublicRoomFilterHandler; injected into RoomsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:97) and constructed inside its GetReadSpecificationAsync override (RoomsController.cs:126).

SessionizeSyncResult

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:21 · Level 0 · record (sealed)

  • What it is: the two-number return value of one Sessionize sync step. It carries how many rows of the step's primary entity were accepted and, where a step also touches a child collection, how many of those were accepted.
  • Depends on: nothing first-party, nothing external. Two int properties, both init-only.
  • Concept: none new. It is a deliberately anaemic result record co-located with the contract that returns it, ISessionizeSyncStrategy (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:7), rather than living in its own file. [Rubric §9, API and Contract Design] assesses whether a contract can grow without breaking its implementors: returning a record instead of a bare int means a future step can report a third number by adding one init property, and the five existing strategies keep compiling untouched.
  • Walkthrough: PrimarySynced (ISessionizeSyncStrategy.cs:24) is the headline count, for example speakers synced. SecondarySynced (ISessionizeSyncStrategy.cs:27) is the optional child count, for example the category items synced alongside their categories. Neither is required, so a strategy that has nothing secondary to report constructs new SessionizeSyncResult { PrimarySynced = n } and leaves the other at its default of zero, which is what four of the five strategies do (for example RoomSyncStrategy.cs:79).
  • Why it's built this way: the counts are what the organizer sees after an import, so they must mean "the domain accepted this row", not "the feed listed this row". Every strategy increments only after the aggregate call succeeded, which is why the record is filled at the end of the loop rather than from the feed's own collection sizes.
  • Where it's used: returned by all five strategies; collected into a List<SessionizeSyncResult> by RefreshFromSessionizeHandler (RefreshFromSessionizeHandler.cs:123-127) and projected into RefreshFromSessionizeResultDTO (RefreshFromSessionizeHandler.cs:144-154).

SessionizeSyncWarnings

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncWarnings.cs:9 · Level 3 · class (internal static)

  • What it is: a one-method helper the sync strategies share when they have to explain, in one short sentence, why the domain refused a row that the Sessionize feed listed.
  • Depends on: Result (MMCA.Common.Shared.Abstractions, imported at SessionizeSyncWarnings.cs:1). Nothing external.
  • Concept introduced, the first-error idiom. A failed Result carries a collection of Error values, but a warning line has room for one reason. FirstErrorMessage (SessionizeSyncWarnings.cs:17-18) uses a C# list pattern, result.Errors is [var first, ..], to bind the head of the collection when one exists and fall back to the literal "Unknown error" when the failure carries none. [Rubric §15, Best Practices and Code Quality] assesses whether recurring micro-logic is expressed once: five strategies needed the same sentence, so the idiom lives in one internal static method instead of five near-copies.
  • Walkthrough: one member. internal static string FirstErrorMessage(Result result) (SessionizeSyncWarnings.cs:17), expression-bodied, reading the head of the collection through a pattern rather than materializing a LINQ query.
  • Why it's built this way: internal and static because this is an implementation detail of a single use case, not a service. There is nothing to inject and nothing to mock, so it is a static call rather than a dependency.
  • Where it's used: CategorySyncStrategy (CategorySyncStrategy.cs:104), RoomSyncStrategy (RoomSyncStrategy.cs:72), SessionSyncStrategy (SessionSyncStrategy.cs:108) and SpeakerSyncStrategy (SpeakerSyncStrategy.cs:131).
  • Caveats: QuestionSyncStrategy does not use it. Its create-failure warning joins every error message with "; " instead (QuestionSyncStrategy.cs:80), so the question path reports all reasons where the other four report the first one.

RefreshFromSessionizeCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeCommand.cs:13 · Level 8 · record (sealed)

  • What it is: the command that asks for one event's data to be re-pulled from Sessionize (UC-6). It is a single-parameter record carrying the event id.
  • Depends on: EventIdentifierType (the module's identifier alias, see the primer), Event (used only to build the cache prefix from its full type name), ConferenceFeatures, and three pipeline markers from MMCA.Common: ICacheInvalidating, ITransactional and IFeatureGated.
  • Concept: the marker-driven decorator pipeline is taught in group 5. What is worth studying here is that one small record opts into three cross-cutting behaviors at once by implementing three interfaces (RefreshFromSessionizeCommand.cs:13). [Rubric §12, Performance & Scalability] assesses whether transactions, caching and feature gating are applied declaratively rather than hand-coded per handler: the handler below contains no transaction call, no cache eviction and no feature-flag check, because all three are decided by the markers on this type. [Rubric §29, Resilience and Business Continuity] assesses whether a risky dependency can be switched off without a deploy: IFeatureGated makes the whole Sessionize integration a runtime toggle.
  • Walkthrough: the positional parameter EventId (RefreshFromSessionizeCommand.cs:13). CachePrefix returns $"{typeof(Event).FullName}:" (RefreshFromSessionizeCommand.cs:16), so a successful refresh evicts the whole Event cache region rather than a single key: the import touches events, rooms, sessions, speakers, categories and questions, so a narrower eviction would leave stale reads behind. FeatureName returns ConferenceFeatures.SessionizeIntegration (RefreshFromSessionizeCommand.cs:19), whose value is the string "Conference.SessionizeIntegration" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:15). ITransactional is what makes the five entity families commit or roll back together.
  • Why it's built this way: all five sync steps write through one IUnitOfWork and one save, so a half-applied import (rooms in, sessions out) is not reachable. Sessions reference rooms and speakers, so a partial commit would leave dangling references; the transactional marker is a correctness requirement here, not a convenience.
  • Where it's used: constructed by EventsController.RefreshAsync (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:334) and passed to the injected ICommandHandler<in TCommand, TResult> (EventsController.cs:54).

SessionizeSyncContext

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:11 · Level 8 · record (sealed)

  • What it is: the single parameter object every sync step receives: the parsed feed, the target event, the unit of work the step opens repositories from, and the two accumulators (warnings, skipped count) the steps write into as they go.
  • Depends on: SessionizeResponse, Event, IUnitOfWork, and List<string> (BCL).
  • Concept introduced, the mutable run context behind an immutable-looking record. Four members are required ... { get; init; } (SessionizeSyncContext.cs:13-16), so the identity of the run (which feed, which event, which unit of work, which warnings list) cannot be swapped by a step. The accumulators are still mutable, in two different ways: Warnings is init-only yet holds a List<string> whose contents every step appends to, and SkippedSoftDeleted is a plain { get; set; } counter (SessionizeSyncContext.cs:19) each step increments when it meets a soft-deleted row the feed still lists (BR-136). [Rubric §1, SOLID] assesses interface and parameter-shape discipline: bundling the run state into one type is what lets ISessionizeSyncStrategy keep a two-parameter signature that never changes when one step needs an extra input.
  • Walkthrough: Response (:13) is the deserialized feed. Event (:14) is the tracked aggregate the handler loaded with its Rooms and EventSpeakers navigations, which is why RoomSyncStrategy and SpeakerSyncStrategy can consult those collections without a query. UnitOfWork (:15) is the shared unit of work: every strategy resolves its repositories from it, which is what keeps all five steps inside one transaction and one change tracker. Warnings (:16) is the running list that ends up on the response DTO. SkippedSoftDeleted (:19) is the running count of soft-deleted rows skipped.
  • Why it's built this way: shared mutable state is normally a smell. It is safe here for one reason visible in the orchestrator: the strategies run strictly sequentially in a foreach (RefreshFromSessionizeHandler.cs:124-127), never concurrently. That sequencing is also a hard dependency requirement (categories before speakers and sessions, rooms before sessions), so the context's design and the execution order reinforce each other.
  • Where it's used: created once per command (RefreshFromSessionizeHandler.cs:115-121) and passed to each strategy's SyncAsync.
  • Caveats: nothing in the type enforces the sequential assumption. List<string> and the int counter are not thread-safe, so a change that ran the independent steps in parallel would need a concurrent collection and an interlocked counter.

ISessionizeSyncStrategy

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/ISessionizeSyncStrategy.cs:7 · Level 9 · interface

  • What it is: the one-method contract for "synchronize one entity family from the Sessionize feed into the domain". Five implementations exist, one per family: categories, rooms, questions, speakers, sessions.
  • Depends on: SessionizeSyncContext and SessionizeSyncResult (the latter declared in the same file at ISessionizeSyncStrategy.cs:21).
  • Concept introduced, the Strategy pattern applied to an import pipeline. [Rubric §2, Design Patterns] assesses whether a pattern solves a real structural problem instead of adding ceremony. One Sessionize payload covers five entity families with different upsert rules, different reserved-id guards and different child collections. Written as one method that would run to several hundred lines at a cyclomatic complexity the analyzers reject at error severity. Split behind this interface, each family's rules sit in their own file and the orchestrator holds only the order. [Rubric §15, Best Practices & Code Quality] assesses whether independent concerns are isolated so that a change to room handling cannot break speaker handling: the five files share nothing but this signature and the context type. [Rubric §14, Testability] assesses whether a unit can be exercised without its collaborators: each strategy is a stateless object with a single method taking a context, so a test instantiates one directly and asserts on the returned counts and the context's warnings (for example MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionize/RoomSyncStrategyTests.cs:11).
  • Walkthrough: one member, Task<SessionizeSyncResult> SyncAsync(SessionizeSyncContext context, CancellationToken cancellationToken) (ISessionizeSyncStrategy.cs:15). There is no Order property and no entity-type discriminator: order is the orchestrator's business, declared once in its static array.
  • Why it's built this way: passing a context rather than four parameters means a step that later needs another input costs one added property on the context, not a signature change rippling through five implementations. Returning a record rather than an int gives the same freedom on the way out.
  • Where it's used: implemented by CategorySyncStrategy, RoomSyncStrategy, QuestionSyncStrategy, SpeakerSyncStrategy and SessionSyncStrategy; consumed only by RefreshFromSessionizeHandler.
  • Caveats: the implementations are not registered in DI. The handler holds five instances in a static readonly ISessionizeSyncStrategy[] it constructs itself (RefreshFromSessionizeHandler.cs:29-36), so "add a strategy" means editing that array, not adding a registration.

CategorySyncStrategy

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:12 · Level 10 · class (internal sealed)

  • What it is: the first step of the import. It upserts Category rows and their CategoryItem children from the feed, and it runs first because speakers and sessions reference category items.

  • Depends on: SessionizeSyncContext, SessionizeCategory, SessionizeCategoryItem, Category, IRepository<TEntity, TIdentifierType> resolved from the context's unit of work, and SessionizeSyncWarnings. Externals: System.Globalization for invariant-culture id formatting.

  • Concept introduced, the four-phase upsert shape the other four strategies reuse. Read it once here and the remaining strategies become variations on it.

    1. Bulk pre-load. The strategy resolves the repository once (CategorySyncStrategy.cs:16), projects the feed's ids (:21) and issues a single GetByIdsAsync with includes: [nameof(Category.CategoryItems)], asTracking: true and ignoreQueryFilters: true (:22-27), then indexes the result by id (:28). One query replaces N GetByIdAsync calls. [Rubric §12, Performance and Scalability] assesses whether repeated data access is batched: a full ADC re-import walks hundreds of feed rows, so an id-at-a-time lookup would be an N+1 against the same table.
    2. Discriminate. For each feed row (:32): if a stored row exists and is soft-deleted, bump context.SkippedSoftDeleted and skip (:36-40, BR-136). If it exists and is live, call the aggregate's Update (:42). If it does not exist, go to the Create factory (:99).
    3. Sync children. SyncCategoryItems (:62-90) compares feed items against the loaded CategoryItems collection by id, skips soft-deleted ones (:71-75), and routes the rest to UpdateCategoryItem or AddCategoryItem on the parent aggregate (:79-83), never to a child repository.
    4. Batch add and report. New categories accumulate in a local list and are flushed with one AddRangeAsync (:54-57), then the counts are returned (:59).

    [Rubric §4, DDD] assesses whether invariants stay inside aggregates: every mutation in this file is a call on Category, and category items are only ever reached through their parent, so the aggregate boundary holds even under a bulk import.

  • Walkthrough of the specifics: CreateNewCategory (:92-118) is where the failure policy shows. Category.Create returns a Result; on failure the strategy appends a warning naming the title and id and quoting SessionizeSyncWarnings.FirstErrorMessage (:104), then returns without counting the row. On success it adds every feed item to the fresh aggregate (:109-113), queues the category (:115) and increments the count through a ref int parameter (:116). Note the asymmetry between the two paths: for an existing category items are reconciled against what is stored, while for a new one they are simply added, because there is nothing to reconcile against.

  • Why it's built this way: a single bad row must not abort an import of hundreds, so the strategy degrades: warn, skip, keep going, and let the organizer read the warnings on the response. ignoreQueryFilters: true is required rather than optional here, because a feed id is the row's literal primary key: a soft-deleted category is invisible under the global filter, so without the flag the strategy would treat it as new and the insert would violate the primary key and roll the whole refresh back.

  • Where it's used: instance zero of the handler's strategy array (RefreshFromSessionizeHandler.cs:31); its two counts become CategoriesSynced and CategoryItemsSynced on the response (RefreshFromSessionizeHandler.cs:146-147). Covered by MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionize/CategorySyncStrategyTests.cs:15.


QuestionSyncStrategy

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/QuestionSyncStrategy.cs:12 · Level 10 · class (internal sealed)

  • What it is: the step that upserts Question rows. It adds two concerns the category step does not have: deciding which entity a question belongs to, and refusing feed ids that would collide with organizer-created questions.
  • Depends on: SessionizeSyncContext, SessionizeQuestion, Question, QuestionInvariants, and the repository from the context's unit of work.
  • Concept: the four-phase shape is taught under CategorySyncStrategy. What is new here is the reserved identifier band. QuestionInvariants.ManualIdRangeStart and ManualIdRangeEnd are 999_999_000 and 999_999_999 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:40 and :43). Organizer-created questions take ids from that band; Sessionize allocates from far below it. A feed id landing inside the band would silently overwrite a question a human wrote, so the strategy filters those rows out with a warning before anything reaches the database (QuestionSyncStrategy.cs:30-41). [Rubric §8, Data Architecture] assesses how identity is allocated when rows arrive from two sources into one table: the split-range convention is what lets imported and hand-created questions share a key space without a mapping table.
  • Walkthrough:
    • Answer-derived classification (:20-27): before touching the database, the strategy builds two hash sets, the question ids answered by speakers and the question ids answered by sessions, by flattening QuestionAnswers across the feed's speakers and sessions.
    • Reserved-id filter (:30-41): the guard above, producing validQuestions.
    • Bulk pre-load (:44-50): one GetByIdsAsync over the surviving ids with tracking on and query filters off, indexed by id.
    • Classification and mapping: DeriveQuestionEntity (:101-118) returns "Session" when a session answered the question, "Speaker" when a speaker did, and otherwise falls back to the stored value before defaulting to "Session" (:117). That ordering matters: a feed that happens to carry no answers this run offers no classification signal, so an existing question keeps its recorded entity instead of being reclassified. MapSessionizeQuestionType (:123-129) collapses the feed's open type vocabulary onto the three domain-valid values, passing "Rating" and "Email" through and mapping everything else (Short_Text, Long_Text, Url, YesNo) to "Text".
    • Update or create (:61-83): a soft-deleted match is skipped and counted (:63-67, BR-136); a live match is updated with existingQuestion.IsRequired passed back in (:69), so the organizer's required flag survives a re-sync. A new question is created with isRequired: false and questionSource: "Sessionize" (:73), which is how imported questions stay distinguishable from hand-created ones; a failed create warns and skips (:80-81).
    • Batch add and report (:88-93).
  • Why it's built this way: classification cannot come from the feed's question record itself, only from which side of the payload answered it, which is why the two hash sets are computed up front rather than per row. Preserving IsRequired and the stored entity value on update is the same principle the room step applies to organizer-entered fields: the import owns the fields Sessionize sends and nothing else.
  • Where it's used: instance two of the handler's array (RefreshFromSessionizeHandler.cs:33), reported as QuestionsSynced (RefreshFromSessionizeHandler.cs:149). MapSessionizeQuestionType is internal static so it can be exercised directly: the assembly grants InternalsVisibleTo to the application test project (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/MMCA.ADC.Conference.Application.csproj:3), and the tests live at MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionize/QuestionSyncStrategyTests.cs:16.

RoomSyncStrategy

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:20 · Level 10 · class (internal sealed)

  • What it is: the step that upserts Room rows. Rooms are children of the Event aggregate, so every mutation goes through the event, and this step carries the most defensive id handling of the five.
  • Depends on: SessionizeSyncContext, SessionizeRoom, Event, Room, EventInvariants, Result, SessionizeSyncWarnings, and IReadRepository<TEntity, TIdentifierType>.
  • Concept introduced, reading a child entity without granting it a write repository. Room is not an aggregate root, so the strategy resolves GetReadRepository<Room, RoomIdentifierType> (RoomSyncStrategy.cs:27) rather than a full repository: it may load and track the rows, but it cannot add or remove them directly. All writes route through context.Event (:109-122). [Rubric §4, DDD] assesses whether the aggregate root remains the only write entry point for its children, and that one line is the mechanical expression of the rule. [Rubric §11, Security] and [Rubric §8, Data Architecture] both bear on the id guards below: the strategy treats an external id as untrusted input that could point at another event's row or at an organizer-owned one.
  • Walkthrough:
    • Reserved-band filter (:87-88 calling the predicate at :93-102): ExcludeReservedIds drops any feed room whose id falls between EventInvariants.RoomManualIdRangeStart and RoomManualIdRangeEnd, which are 999_999_000 and 999_999_999 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:66 and :68), warning for each (:97). This mirrors the question-side guard exactly.
    • Unscoped id lookup (:43-49): one GetByIdsAsync with asTracking: true and ignoreQueryFilters: true, and deliberately not filtered by EventId. The reasoning is written into the file (:32-42): Sessionize allocates room ids from a global sequence, and a room id carries straight through as the row's primary key, so an id can already belong to another event's room. Filtering by event would hide that row, the strategy would treat the id as new, and the insert would hit the primary key and roll back the entire refresh across all five families. Loading it unscoped and skipping it keeps the damage at the single offending room.
    • Resolution order (:53-54): the aggregate's own Rooms collection is consulted first, and only if that misses does the strategy fall back to the unscoped dictionary.
    • Ownership guard (:59-63): if the row came only from the unscoped lookup and its EventId is not this event's, warn and skip. The comment records why the check is conditional (:56-58): anything already on the aggregate belongs to this event by construction, and a room added earlier in the same run still carries EventId 0 until EF assigns it on save.
    • Apply (:109-122): a three-way switch expression. No stored room means AddRoom (:112); a soft-deleted one means RestoreRoom (:113); otherwise UpdateRoom, which reads Capacity, Floor, Location and AccessibilityInfo back off the stored room (:118-121) because Sessionize never sends those. That is how organizer-entered room detail survives a re-sync.
    • Count acceptances only (:70-76): the aggregate can legitimately refuse a room (a name the feed repeats, a blank name). A refusal produces a warning and no increment, so the reported count and the warnings list always add up.
  • Why it's built this way: the whole refresh is one transaction (ITransactional on RefreshFromSessionizeCommand), which makes any uncaught constraint violation an all-or-nothing loss. Each guard here converts a would-be transaction abort into a single skipped row plus a warning line. [Rubric §29, Resilience and Business Continuity] assesses whether a partial upstream defect degrades gracefully rather than taking the operation down.
  • Where it's used: instance one of the handler's array (RefreshFromSessionizeHandler.cs:32), reported as RoomsSynced (RefreshFromSessionizeHandler.cs:148). Covered by MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionize/RoomSyncStrategyTests.cs:11.

SessionSyncStrategy

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionSyncStrategy.cs:14 · Level 10 · class (internal sealed)

  • What it is: the last step of the import. It upserts Session rows and their three join collections (speakers, category items, question answers), and it runs last because a session references rooms, speakers and category items the earlier steps created.
  • Depends on: SessionizeSyncContext, SessionizeSession, SessionizeQuestionAnswer, Session, SessionizeSyncWarnings, and the repository from the context's unit of work.
  • Concept introduced, reactivate rather than re-add (BR-135). The session and its collections are loaded with ignoreQueryFilters: true (SessionSyncStrategy.cs:23-28), so the loaded SessionSpeakers, SessionCategoryItems and SessionQuestionAnswers collections carry the removed associations too. SyncSessionChildren (:117-159) uses that: for a feed association with no live match it first looks for a soft-deleted row with the same key and calls RestoreSessionSpeaker (:132) or RestoreSessionCategoryItem (:149), and only adds a new row when none exists (:136 and :153). Adding instead would leave the removed row in place and double the association. [Rubric §8, Data Architecture] assesses whether soft-delete is handled consistently on the write path as well as the read path: here the filters are turned off precisely so the write path can see and revive what the read path hides.
  • Walkthrough:
    • Bulk pre-load (:23-29) with all three child collections included.
    • Advisory time validation (:53-71): ValidateSessionTimes warns when a session starts before the event's StartDate or ends after its EndDate (BR-86, :56-64) and when EndsAt is at or before StartsAt (BR-122, :67-70). None of these reject the session: the row is imported as-is and the organizer decides. [Rubric §24, Forms, Validation and UX Safety] assesses whether a system distinguishes a hard invariant from an advisory: schedule anomalies in a live conference feed are usually real and in flight, so blocking the import would be worse than reporting it.
    • Resolve or create (:73-115): a soft-deleted match is skipped and counted (:81-85, BR-136). A live match is updated with the feed's fields, while AccessibilityInfo and ResourceLinks are read back off the stored session (:93) because Sessionize does not send them. A miss goes to Session.Create (:97-103), which receives null for those same two fields and the event id from the context; a failed create warns and returns null (:104-110).
    • Children (:117-176): speakers and category items follow the restore-or-add shape above; SyncSessionQuestionAnswers (:161-176) updates the live answer for a question id if one exists and adds one otherwise, keyed on QuestionId with an IsDeleted guard (:165-166).
    • Batch add and report (:45-50).
  • Why it's built this way: an import that runs repeatedly against a moving feed must be idempotent in the practical sense, that re-running it does not multiply rows. Keying every child comparison on the domain id plus an IsDeleted check, and preferring restore over insert, is what delivers that.
  • Where it's used: instance four of the handler's array (RefreshFromSessionizeHandler.cs:35), reported as SessionsSynced (RefreshFromSessionizeHandler.cs:151). Covered by MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionize/SessionSyncStrategyTests.cs:15.

SpeakerSyncStrategy

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:14 · Level 10 · class (internal sealed)

  • What it is: the step that upserts Speaker rows, links each speaker to the event through EventSpeaker, and syncs each speaker's category items and question answers. It also turns the feed's loose link list into typed social fields.
  • Depends on: SessionizeSyncContext, SessionizeSpeaker, SessionizeLink, SessionizeQuestionAnswer, Speaker, Event, EventSpeaker, SessionizeSyncWarnings, plus both IRepository<TEntity, TIdentifierType> and IReadRepository<TEntity, TIdentifierType>.
  • Concept introduced, reviving an association the aggregate cannot see. The event was loaded by the handler with the global query filters on, so a removed EventSpeaker link is simply absent from context.Event.EventSpeakers. Re-adding it would create a second row alongside the removed one. LoadDeletedEventSpeakersAsync (:81-99) closes that hole: it opens a read repository for EventSpeaker (:85), queries this event's deleted links with ignoreQueryFilters: true and asTracking: true (:87-92), and groups them by speaker id, taking the first of each group because a speaker added and removed repeatedly can carry more than one removed link (:96-98). The link decision then reads: skip if a live link exists, restore a deleted one if either the aggregate or that dictionary has it, otherwise add (:48-61, BR-135). [Rubric §8, Data Architecture] assesses whether the soft-delete convention is applied coherently across an aggregate boundary, which is exactly the trap this method sidesteps.
  • Walkthrough:
    • Bulk pre-load (:23-29) with SpeakerCategoryItems and SpeakerQuestionAnswers included, filters off, tracking on.
    • Social-link extraction (:183-215): ExtractSocialLinks walks the feed's links and matches LinkType case-insensitively, sending "Twitter" through ExtractTwitterHandle (:197-200), "LinkedIn" to the LinkedIn field (:201-204), "Blog" and "Company_Website" to the website field (:205-209), and falling back to a URL-content check for "github" (:210-213). ExtractTwitterHandle (:217-240) strips six known twitter.com and x.com prefixes, trims a leading @ and surrounding slashes, and returns null for an empty result. The #pragma warning disable S5332 around the replacement chain (:227-237) is deliberate and documented in place: the plain-http literals are input patterns being removed, not addresses this code connects to, and dropping them would leave legacy Sessionize profile links unparsed.
    • Create or update (:101-143): a soft-deleted speaker is skipped and counted (:113-117). A live one is updated with existingSpeaker.Email?.Value passed back in (:120) so the stored email survives, since the feed does not carry it. A new speaker goes through Speaker.Create with a null email (:126) and is then immediately updated (:137-140) because the factory does not accept the social fields; a failed create warns and returns null (:127-133).
    • Children (:145-181): SyncCategoryItems (:145-164) uses the restore-or-add shape; SyncQuestionAnswers (:166-181) updates a live answer by its id or adds a new one.
    • Batch add and report (:67-72).
  • Why it's built this way: the module treats Sessionize as the owner of the fields Sessionize sends and the organizer as the owner of everything else, so every update call in this file threads the locally held values (email here, room detail in the room step, IsRequired in the question step) back through the aggregate rather than blanking them. [Rubric §30, Compliance, Privacy and Data Governance] assesses whether personal data is written only from the source entitled to set it: the speaker email is never overwritten by an import.
  • Where it's used: instance three of the handler's array (RefreshFromSessionizeHandler.cs:34), reported as SpeakersSynced (RefreshFromSessionizeHandler.cs:150). Covered by MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategyTests.cs:12.

GetPublicRoomFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.GetPublicRoomFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:16 · Level 11 · class (sealed)

  • What it is: the handler for GetPublicRoomFilterQuery. It asks PublicConferenceVisibility for the published event ids and returns a Room.EventId IN (...) specification. It is the simplest member of the public-filter family: one resolver call, one predicate, no failure path.
  • Depends on: IUnitOfWork (:17, injected only to hand on to the resolver), PublicConferenceVisibility, InlineSpecification<TEntity, TIdentifierType> and its base Specification<TEntity, TIdentifierType>, Room, and Result. It implements IQueryHandler<in TQuery, TResult> to Result<Specification<Room, RoomIdentifierType>> (:18).
  • Concept: the query-that-returns-a-specification shape is taught under GetPublicSessionFilterHandler: a handler whose result is a reusable predicate rather than data, so the visibility rule is resolved once in the Application layer and applied by whichever read the controller is serving. [Rubric §6, CQRS and Event-Driven] assesses whether reads are expressed as explicit, single-purpose query objects; this is a query whose payload is the filter itself. [Rubric §11, Security] assesses the anonymous read surface: the rule here is one line of predicate, and it is the only thing standing between an unpublished event's venue layout and an anonymous caller.
  • Walkthrough:
    1. HandleAsync (:21-23) takes the marker query and a CancellationToken.
    2. Resolve the published event ids: PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:25-27). Inside the shared resolver that is a scalar, untracked projection of Event.Id where IsPublished, materialized once so the list embedded in the predicate is stable and EF-translatable to IN (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:36-47).
    3. Wrap r => publishedEventIds.Contains(r.EventId) in an InlineSpecification<TEntity, TIdentifierType> and return Result.Success (:29-30). Nothing here can fail, so Result is used for pipeline uniformity rather than to carry an error.
  • Why it's built this way: the summary (:10-15) says the id-list shape mirrors the sponsor, speaker, and session public filters, so no navigation join is required and the criteria stays translatable on any engine (ADR-018). Room is the easy case for that rule because it carries a real EventId column (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Room.cs:38), so the parent's visibility is expressible directly on the child's own column, with no traversal and no join table. Sourcing the id list from the shared PublicConferenceVisibility rather than restating IsPublished here is what keeps one definition of "published" behind every public read.
  • Where it's used: RoomsController injects it as an IQueryHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:97) and calls it from its GetReadSpecificationAsync override (RoomsController.cs:120-130), which short-circuits to null when IsPrivileged (:123-124, backed by currentUserService.IsPrivilegedConferenceReader() at :104), so Organizer and ContentEditor readers see every room. GetReadSpecificationAsync is the framework's read hook declared on EntityControllerBase<TEntity, TEntityDTO, TIdentifierType> (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:597) and consumed by every base read action (EntityControllerBase.cs:115, :170, :264, :378, :422), so overriding it once scopes all four of the controller's [AllowAnonymous] reads at a stroke: the unpaged list (RoomsController.cs:136-144), the paged list (:152-165), the lookup (:173-179) and the by-id read (:186-195). Those four actions are attribute-only passthroughs whose bodies delegate straight to base; each also carries [OutputCache(PolicyName = "RoomsCache")] (:138, :154, :175, :188), and the three write actions evict the conference:rooms output-cache tag afterwards (:225, :254, :272).
  • Testing: there is no per-handler unit-test class for this filter; it is covered from the controller side by RoomsControllerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/Controllers/Events/RoomsControllerTests.cs:26), which mocks the handler (:32) and asserts the paired behavior on all four reads: the specification is applied for an anonymous or Attendee caller and never resolved for an Organizer or ContentEditor one (:199 and :214 unpaged, :229 and :245 paged, :261 and :277 lookup, :292 and :312 by-id). SetupPublicRoomFilter stands in a filter of its own, r => r.EventId == 1 (:329-333), and VerifyFilterNeverResolved (:335-338) is what proves the privileged path never even calls the handler. [Rubric §14, Testability]: the branch worth protecting is the privileged short-circuit, because a regression there is silent (privileged callers would simply see less), and these are the tests that would catch it.
  • Caveats / not-in-source: the override maps a failed Result to null, that is, to no filter at all (RoomsController.cs:129), the same fail-open shape noted on GetPublicEventSpeakerFilterHandler. No path in this handler produces a failure today. The published-event id list is also materialized into the predicate, so the IN list grows with the number of published events; PublicConferenceVisibility describes that table as bounded at single-digit rows, but nothing in code enforces that bound.

RefreshFromSessionizeHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RefreshFromSessionize · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandler.cs:20 · Level 14 · class (sealed partial)

  • What it is: the command handler behind UC-6. It checks the preconditions, calls the Sessionize API, runs the five strategies in dependency order against one shared context, stamps the refresh on the event, saves everything in one transaction, and returns the per-entity counts and warnings.

  • Depends on: IUnitOfWork, ISessionizeService, ICurrentUserService, TimeProvider (BCL), ILogger<T> (Microsoft.Extensions.Logging), Event, Result and Error, RefreshFromSessionizeCommand, RefreshFromSessionizeResultDTO, SessionizeResponse, SessionizeSyncContext, SessionizeSyncResult, ISessionizeSyncStrategy and its five implementations. Externals: Polly.CircuitBreaker and Polly.Timeout (for the two rejection types it catches) and System.Text.Json.

  • Concept introduced, classifying an upstream failure instead of letting it become a 500. IsSessionizeUnavailable (RefreshFromSessionizeHandler.cs:167-172) treats five exception types as "no usable Sessionize data right now": HttpRequestException, Polly's TimeoutRejectedException and BrokenCircuitException, and JsonException or NotSupportedException. The last two matter because an upstream that serves an HTML error page with a success status makes the JSON read fail on content rather than on transport. The Polly types appear because the client runs behind the standard resilience pipeline from AddServiceDefaults, so an unreachable API often reaches the handler as a pipeline rejection rather than a socket error. Critically, the catch block re-checks cancellation first (:86): a broadened catch must not convert a caller's cancellation into an "upstream is down" answer. [Rubric §13, Observability and Operability] assesses whether operators can tell a dependency outage from a defect: this classification is what lets the controller answer 502 instead of 500. [Rubric §29, Resilience and Business Continuity] assesses graceful degradation against a third-party dependency.

  • Walkthrough:

    • Static strategy array (:28-35): five stateless instances in dependency order (categories, rooms, questions, speakers, sessions), with the ordering rationale in the comment above them (:26-27). static readonly because the strategies hold no state; all state lives in the per-command context.
    • Primary constructor (:19-24): five dependencies. sealed partial is what allows the [LoggerMessage] source-generated log method at the bottom of the file (:173-174).
    • Load the aggregate (:43-54): GetByIdAsync with Rooms and EventSpeakers included and asTracking: true, returning Error.NotFound when the event is missing.
    • Precondition, a configured code (:57-64, BR-6): a blank SessionizeCode fails with Event.Sessionize.NoCode.
    • Precondition, the throttle (:67-75, BR-63): if LastSessionizeRefreshOn is less than five minutes before timeProvider.GetUtcNow(), the handler fails with Event.Sessionize.Throttled and never calls the API. Time comes from an injected TimeProvider, which is what makes the window testable without waiting.
    • Call the API (:78-93), with the classification above.
    • An empty response is success (:96-111): a null response is not an error, since an event may have no data yet. The handler stamps the refresh, saves, and returns a DTO of zeros with no warnings.
    • Run the strategies (:114-126): one context is built and the five SyncAsync calls run sequentially, collecting a SessionizeSyncResult each.
    • Summarize skips (:128-131): a non-zero SkippedSoftDeleted becomes one final warning line naming the count and BR-136.
    • Stamp and save (:134-139): RecordSessionizeRefresh writes the current user id and timestamp onto the aggregate, then unitOfWork.RequestIdentityInsert() (:138) is called before the single SaveChangesAsync (:139). This is the load-bearing detail of the whole use case: Sessionize rows keep their external ids as primary keys in tables whose key columns are IDENTITY, so the unit of work has to wrap the save in SET IDENTITY_INSERT ON/OFF per table. The request flag is declared on IUnitOfWork (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:49), forwarded to the context factory (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/UnitOfWork.cs:76), read and reset on the next save (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:228-229), and routed to the per-table round-splitting path for SQL Server contexts (DbContextFactory.cs:248-250, implemented at DbContextFactory.cs:285).
    • Log and project (:141-153): the source-generated information-level log records the event id, then the five results are read positionally, results[0] through results[4], into the response DTO.

    [Rubric §6, CQRS and Event-Driven] assesses whether writes flow through one explicit handler boundary: this type implements ICommandHandler<in TCommand, TResult> (:24), is wrapped by the decorator pipeline described in group 5, and the controller knows only the interface. [Rubric §3, Clean Architecture] assesses dependency direction: the handler names ISessionizeService, never an HTTP client, so the outbound call is an abstraction the infrastructure layer satisfies with SessionizeService.

  • Why it's built this way: one transaction across five entity families is not an optimization, it is what keeps sessions from referencing rooms or speakers that did not commit. That constraint drives the rest of the design: strategies must not throw on a bad row (a throw would abort everything), they must not provoke primary key collisions (hence the unscoped lookups and the reserved-band guards), and the handler must not save between steps.

  • Where it's used: injected into EventsController as ICommandHandler<RefreshFromSessionizeCommand, Result<RefreshFromSessionizeResultDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:54) and invoked from RefreshAsync (EventsController.cs:329-334), which is [HttpPost("{id}/refresh")] and [Idempotent] (EventsController.cs:327-328). The controller maps the two well-known error codes onto transport: Event.Sessionize.Throttled becomes a 429 with a Retry-After of 300 seconds (EventsController.cs:340-343) and Event.Sessionize.Unavailable becomes a 502 (EventsController.cs:347-348). Unit tests live at MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandlerTests.cs:16, with an integration tier at MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Organizer/SessionizeRefreshTests.cs.

  • Caveats: the DTO projection is positional (:145-150), so the response mapping is coupled to the order of the static array; inserting a sixth strategy anywhere but the end would silently shift every count that follows it. Both RecordSessionizeRefresh calls dereference currentUserService.UserId!.Value (:98 and :134) with the null-forgiving operator, so the handler assumes an authenticated caller and would throw for an anonymous one; the endpoint's authorization is what upholds that assumption.


EventDateRangeRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:113 · Level 0 · class (sealed, generic)

  • What it is: a reusable FluentValidation rule fragment that enforces event date-range integrity: a start date is required, an end date is required, and the end date must fall on or after the start date. It is the only fragment in the Events validation folder that reasons about two properties at once.
  • Depends on: FluentValidation's AbstractValidator<T> (NuGet, primer §3) and System.Linq.Expressions.Expression<> (BCL). No first-party types: the file's MMCA.ADC.Conference.Domain.Events and MMCA.Common.Application.Validation imports (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:3-4) serve its sibling fragments in the same file, not this one.
  • Concept, the cross-field rule fragment. The module-local fragment idiom itself is taught on ActivityEventIdRules<T>, and the framework fragments it composes over live in group-06. What this class adds is the two-property case. A single-field fragment closes over one Expression<Func<T, TProp>>; a cross-field fragment takes two selectors and must read one property while validating the other. FluentValidation's two-argument Must overload is the mechanism: the predicate receives both the instance under validation and the value of the property the rule is attached to, so the sibling property is reachable through a compiled getter. [Rubric §1, SOLID] assesses single responsibility and open/closed adherence: "end after start" is its own fragment rather than a clause bolted onto a name or time-zone rule, so adding a constraint means composing another Include(...) line, never editing an existing fragment. [Rubric §24, Forms, Validation & UX Safety] assesses whether validation is centralized and machine-addressable: one fragment serves both the create and the update path through EventFieldRules<T>, and each of its three rules carries a stable dotted error code beside its human message.
  • Walkthrough: the constructor (EventValidationRules.cs:116-118) takes two Expression<Func<T, DateOnly>> selectors, startDateSelector and endDateSelector. It registers NotEmpty on each, with the distinct codes Event.StartDate.Required (:120-121) and Event.EndDate.Required (:123-124). The cross-field check is the part worth reading closely: the start-date selector is compiled to a delegate once, at construction (var startDateFunc = startDateSelector.Compile();, :126), and a second rule on the end date calls Must((instance, endDate) => endDate >= startDateFunc(instance)) with the message "End Date must be on or after the Start Date" and the code Event.EndDate.BeforeStart (:127-129). Because the delegate is captured in the constructor, the expression tree is compiled per validator instance, not per validated request.
  • Why it's built this way: the same rule exists on the domain side as EventInvariants.EnsureDateRangeIsValid (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:112-119, delegating to CommonInvariants.EnsureEndIsNotBeforeStart under the code Event.DateRange.Invalid), so the fragment is not the only guard: it is the fast, field-attributed one that fires before a handler or an aggregate is touched, while the invariant is the backstop for any caller that bypasses the validator. Splitting the concern into its own fragment is what lets the shared rule set pull in exactly this rule instead of inheriting a monolithic event validator.
  • Where it's used: Included by EventFieldRules<T> as new EventDateRangeRules<T>(p => p.StartDate, p => p.EndDate) (EventValidationRules.cs:147), which is itself included by EventCreateRequestValidator and EventUpdateRequestValidator. The end-before-start case is pinned by EventCreateRequestValidatorTests.Validate_WithEndDateBeforeStartDate_ReturnsError (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/Validation/EventCreateRequestValidatorTests.cs:55).
  • Caveats / not-in-source: NotEmpty on a DateOnly rejects default(DateOnly) (January 1, year 1), so a caller that never sets a date fails the required rule. That is FluentValidation's default-value semantics, not something this fragment states.

IEventFieldsRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/IEventFieldsRequest.cs:12 · Level 0 · interface

  • What it is: the seven-member shape describing exactly the event fields that the create request and the update request validate identically. It exists so one rule set can be declared once over the shape instead of twice over two concrete records.
  • Depends on: nothing. It has no base interface, no attributes, and no imports at all: the file opens straight into the namespace declaration (IEventFieldsRequest.cs:1). DateOnly is BCL.
  • Concept, the validation-shaped interface. Most interfaces in this codebase describe a capability (a handler, a repository, a module). This one describes a field set, and its only purpose is to be a generic constraint. Two request records (EventCreateRequest and EventUpdateRequest) carry more properties than these seven, but the seven are the ones both operations validate the same way. Declaring them as an interface lets EventFieldRules<T> write where T : IEventFieldsRequest and then reach the properties through lambdas (p => p.Name) that bind at the interface, not at either record. [Rubric §1, SOLID] assesses interface segregation and dependency inversion: the interface is exactly as wide as the rule set that consumes it, and the rule set depends on the abstraction rather than on either concrete request. [Rubric §9, API & Contract Design] assesses whether a contract states what it guarantees: every member here is get-only, so the interface promises readability of a field, never mutation of one.
  • Walkthrough: seven get-only members, each with its own doc comment. Name (:15) and TimeZone (:24) are non-nullable string; StartDate (:18) and EndDate (:21) are DateOnly; OrganizerContactEmail (:27), SponsorshipPacketUrl (:30), and TicketingUrl (:33) are nullable string?, matching the three optional fields whose fragments guard on presence. The type's <remarks> block (:8-11) names the deliberate omission: the live-layer moderation default is not on the interface, because only the update request carries it and its rule lives in that operation's own validator.
  • Why it's built this way: without the interface, the shared rules would have to be written twice (once per record) or the rule set would have to be non-generic and duplicated. The interface is the smallest thing that removes the duplication, and its remarks make the per-operation delta explicit rather than leaving a reader to wonder why the moderation default is missing: EventUpdateRequestValidator adds RuleFor(x => x.QuestionModerationDefault).IsInEnum() under the code Event.QuestionModerationDefault.Invalid after including the shared set (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:13-18).
  • Where it's used: as the generic constraint on EventFieldRules<T> (EventValidationRules.cs:141), and implemented by EventCreateRequest (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:11, alongside ICreateRequest and ICacheInvalidating) and EventUpdateRequest (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequest.cs:7).
  • Caveats / not-in-source: the interface lives in the Events.Validation namespace, not beside the requests, so both request files take a using MMCA.ADC.Conference.Application.Events.Validation; to implement it (EventCreateRequest.cs:1, EventUpdateRequest.cs:1). Nothing enforces that a future event request implements it: a third request could re-declare the same seven fields and silently miss the shared rule set.

RoomCapacityRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:37 · Level 0 · class (sealed, generic)

  • What it is: a reusable rule fragment enforcing that a room's capacity, when supplied, is strictly positive. Capacity is optional (int?), so the rule only fires when a value is present.
  • Depends on: FluentValidation's AbstractValidator<T> and System.Linq.Expressions.Expression<>. No first-party types: unlike the string fragments in the same file it reads no domain constant.
  • Concept: the module-local rule fragment taught on ActivityEventIdRules<T>. The wrinkle worth noticing here is the conditional: .When(x => selector.Compile()(x) is not null) (RoomValidationRules.cs:43) guards the GreaterThan(0) rule (:42) so a null capacity is silently accepted rather than reported as invalid. [Rubric §24, Forms, Validation & UX Safety] assesses whether validation matches a field's real optionality: an absent optional numeric field should not raise an error, while a present but nonsensical one should.
  • Walkthrough: the constructor takes an Expression<Func<T, int?>> selector (:40), chains GreaterThan(0) with the message "Capacity must be greater than 0" and the error code Room.Capacity.NotPositive (:41-42), then applies the null-guard When clause (:43).
  • Why it's built this way: separating the presence check (When) from the value check keeps the "optional but bounded" semantics in one place: a room without a known capacity is valid, a room claiming a non-positive capacity is not. The domain states the same rule as EventInvariants.EnsureRoomCapacityIsValid, which delegates to CommonInvariants.EnsureNullableIntIsPositive under the code Room.Capacity.Invalid (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:127-132), tagged BR-93 in its doc comment (:121), so the fragment and the invariant agree on treating null as acceptable.
  • Where it's used: Included by AddRoomCommandValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommandValidator.cs:13) and UpdateRoomCommandValidator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:13), both on p => p.Capacity. Both branches are pinned by AddRoomCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/Validation/CommandValidatorTests.cs:100, :108).
  • Caveats / not-in-source: unlike EventDateRangeRules<T>, which compiles its selector once at construction, the When predicate here calls selector.Compile() inside the lambda (:43), so the expression is recompiled on each evaluation rather than cached.

RoomSortRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:26 · Level 1 · class (sealed, generic)

  • What it is: a reusable rule fragment enforcing that a room's sort-order value is non-negative. It is two lines long, because it inherits the whole rule from a framework base and supplies only the field label and the error code.
  • Depends on: NonNegativeIntRules<T>, its base class from the framework (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:122), plus System.Linq.Expressions.Expression<> (BCL). No domain constant.
  • Concept, subclassing a framework fragment while keeping the module's error code. The framework base takes an optional errorCode parameter (CommonValidationRules.cs:124) and applies it to every rule it declares through the internal WithOptionalErrorCode helper (:30-32), which returns the rule untouched when the code is null. That parameter is why a module fragment can inherit the base and still hand clients a stable machine-readable code, which is the trade the hand-written fragments in this module otherwise have to make by writing the rule chain out. [Rubric §9, API & Contract Design] assesses the stability of the error contract clients consume: Room.Sort.Negative travels with the message rather than the message having to be parsed. [Rubric §15, Best Practices & Code Quality] assesses whether a constraint lives in exactly one place: the comparison itself is written once in the framework, and the module supplies only the naming.
  • Walkthrough: the class declares : NonNegativeIntRules<T> (RoomValidationRules.cs:27) and its constructor takes an Expression<Func<T, int>> selector, forwarding base(selector, "Sort", "Room.Sort.Negative") (:29-30). The base registers a single GreaterThanOrEqualTo(0) rule with the message "Sort must be greater than or equal to 0" and attaches the supplied code (CommonValidationRules.cs:124-126).
  • Why it's built this way: GreaterThanOrEqualTo(0) rather than GreaterThan(0) because zero is a legitimate "first in the list" position, which is why the framework's PositiveIntRules<T> (CommonValidationRules.cs:100-105) is the wrong base to reuse here. Sort order drives deterministic room ordering in the UI, so a negative value is rejected at the application boundary.
  • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:12) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:12), both on p => p.Sort, alongside RoomCapacityRules<T>. Both validators have a negative-sort test (CommandValidatorTests.cs:92, :137).
  • Caveats / not-in-source: nothing in EventInvariants mirrors this rule, so unlike room name and room capacity the sort order has no domain-side backstop: this fragment is the only guard on the path.

EventNameRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:13 · Level 7 · class (sealed, generic)

  • What it is: a reusable rule fragment for the event name: non-empty and bounded by EventInvariants.NameMaxLength, which resolves to EventDTO.NameMaxLength, 500 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:17, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventDTO.cs:19).
  • Depends on: RequiredStringRules<T>, its base class from the framework (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:41), and EventInvariants for the bound, which in turn re-exports the constant from EventDTO.
  • Concept: the same fragment idiom taught on ActivityEventIdRules<T>, but taken to its terse extreme: instead of writing a rule chain, this fragment subclasses the framework's shared RequiredStringRules<T> and passes it a field label and a bound, delegating the NotEmpty plus MaximumLength wiring to the base (CommonValidationRules.cs:43-46). [Rubric §4, DDD] assesses ubiquitous language in code: the class is named EventNameRules after the domain field, not a generic "NameValidator". [Rubric §15, Best Practices & Code Quality] assesses reuse across repos: the module writes two lines, because the shared base owns the message shape and the length constant lives once in the domain.
  • Walkthrough: one constructor taking an Expression<Func<T, string>> selector (:16), whose entire body is the base call base(selector, "Event Name", EventInvariants.NameMaxLength) (:17). The base produces the two messages "You must enter a Event Name" and "Event Name cannot be longer than 500 characters" (CommonValidationRules.cs:44-46).
  • Why it's built this way: the length constant is the same value the domain-side invariant enforces (EventInvariants.EnsureNameIsValid, EventInvariants.cs:71-74, error codes Event.Name.Empty and Event.Name.TooLong) and the same one the EF configuration and the form field use, so the validation message, the aggregate guard, and the schema cannot drift apart.
  • Where it's used: Included by EventFieldRules<T> on p => p.Name (EventValidationRules.cs:145), which reaches both EventCreateRequestValidator and EventUpdateRequestValidator. The empty-name case is pinned at EventCreateRequestValidatorTests.cs:33.
  • Caveats / not-in-source: this fragment passes no errorCode to the base, even though the base accepts one (CommonValidationRules.cs:43), so the event name yields a human message with no machine-readable code, unlike RoomNameRules<T> or RoomSortRules<T>. The base message also reads "You must enter a Event Name", an article-agreement artifact of building the message from the field label.

EventOrganizerContactEmailRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:57 · Level 7 · class (sealed, generic)

  • What it is: a rule fragment for the event's optional organizer contact email. When the caller supplies a value it must be a well-formed email address no longer than EventInvariants.OrganizerContactEmailMaxLength, 255 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:38, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventDTO.cs:40); when the caller leaves it blank, no rule runs at all.
  • Depends on: EmailRules<T>, the shared framework fragment it wraps (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:64-70), and EventInvariants. It inherits AbstractValidator<T> directly and uses System.Linq.Expressions.Expression<>.
  • Concept, conditional inclusion of a required-field fragment. The fragments seen so far either always apply (EventNameRules<T>) or are unconditionally length-only (RoomFloorRules<T>). This one is different: the shared EmailRules<T> it wants to reuse starts with NotEmpty (CommonValidationRules.cs:67), which is exactly wrong for an optional field. Rather than fork a near-copy of the shared fragment, the constructor compiles the selector once (var accessor = selector.Compile();, EventValidationRules.cs:62) and wraps the whole Include in FluentValidation's When(...), so the required-email rules are only registered against instances that actually carry a value (:64-65). [Rubric §1, SOLID] assesses open/closed adherence: optionality is composed around the shared rule, never by modifying it. [Rubric §24, Forms, Validation & UX Safety] assesses whether validation matches the field's real optionality: an organizer who never fills the field sees no error, while a typo in a filled field is still rejected as a bad address.
  • Walkthrough: the constructor takes an Expression<Func<T, string>> selector (:60), compiles it to a delegate (:62), then calls When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new EmailRules<T>(selector, "Organizer Contact Email", EventInvariants.OrganizerContactEmailMaxLength))) (:64-65). Inside the guard the shared base contributes three chained rules: NotEmpty, EmailAddress, and MaximumLength, all with messages built from the "Organizer Contact Email" field label (CommonValidationRules.cs:66-69).
  • Why it's built this way: the field's doc comment states the product reason (:51-55): the value is optional, and an empty value means the public page falls back to the configured support address rather than showing nothing. That fallback is real. PublicEventDetail seeds _supportEmail from Configuration["Support:Email"] (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Events/PublicEventDetail.razor.cs:43, :56) and, once the event loads, keeps the configured address when Event.OrganizerContactEmail is blank and uses the event's own address otherwise (:142-144). Rejecting a blank value at the boundary would break that intended default.
  • Where it's used: Included by EventFieldRules<T> as p => p.OrganizerContactEmail! (EventValidationRules.cs:148), so it reaches both EventCreateRequestValidator and EventUpdateRequestValidator. Blank, valid, malformed, and overlong cases each have a test (EventCreateRequestValidatorTests.cs:83, :93, :103, :113).
  • Caveats / not-in-source: the selector type is non-nullable string while the underlying interface member is string? (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/IEventFieldsRequest.cs:27), which is why the call site passes the null-forgiving p => p.OrganizerContactEmail!. The ! only silences the compiler; the runtime null is handled by the When guard, and that combination is what makes it safe. There is also no domain-side invariant for this field: EventInvariants defines the length constant (:37) and the EF configuration applies it to the column (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Events/EventConfiguration.cs:60-62), but no Ensure... method validates the address, so this fragment is the only format check on the path.

EventSponsorshipPacketUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:77 · Level 7 · class (sealed, generic)

  • What it is: a rule fragment for the event's optional sponsorship-packet URL. When a value is supplied it must be no longer than EventInvariants.SponsorshipPacketUrlMaxLength, 2000 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:41, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventDTO.cs:43), and an absolute http or https URL.
  • Depends on: AbsoluteUrlRules<T>, the shared framework fragment it wraps (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:85-92), and EventInvariants. It inherits AbstractValidator<T> directly. Transitively it depends on CommonInvariants, because the framework fragment delegates its scheme predicate to CommonInvariants.EnsureUrlIsWellFormed (CommonValidationRules.cs:91-92).
  • Concept, the scheme check as a rendering-safety rule. Structurally this is the same conditional-inclusion shape taught on EventOrganizerContactEmailRules<T>: compile the selector once, then Include a shared fragment inside a When guard. What it adds is which fragment it wraps. A length-only bound accepts javascript:alert(1) and data:application/pdf;base64,..., and those values become executable the moment a link or an image renders them; the shared fragment's remarks say exactly that (CommonValidationRules.cs:76-83). AbsoluteUrlRules<T> therefore adds Must(BeAnAbsoluteHttpUrl) on top of the same MaximumLength, and the predicate resolves through CommonInvariants.EnsureUrlIsWellFormed (MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:293-297), whose private IsAbsoluteHttpUrl requires Uri.TryCreate(url, UriKind.Absolute, ...) to succeed with an ordinal scheme match against http or https (:436-439). [Rubric §11, Security] assesses whether untrusted input is constrained before it reaches a rendering surface: the string is validated as a string, deliberately, before anything turns it into a Uri (the suppression justification at CommonInvariants.cs:289-292 spells that reasoning out). [Rubric §26, Front-End Security] assesses the same question from the browser's side: the value ends up in an Href, so a non-http scheme reaching persistence would be a stored script vector.
  • Walkthrough: the constructor takes an Expression<Func<T, string?>> selector (:80), note the nullable string? here as against the non-nullable selector of its email sibling, compiles it (:82), and registers When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new AbsoluteUrlRules<T>(selector, "Sponsorship Packet URL", EventInvariants.SponsorshipPacketUrlMaxLength))) (:84-85). The shared base contributes two rules: MaximumLength with the message "Sponsorship Packet URL cannot be longer than 2000 characters", then Must(BeAnAbsoluteHttpUrl) with the message "Sponsorship Packet URL must be an absolute http or https URL" (CommonValidationRules.cs:87-89).
  • Why it's built this way: the doc comment gives both reasons (:69-75). The product reason is that the field is optional, and an empty value means the landing page and the public sponsor page hide the sponsorship call to action rather than rendering a dead link: PublicSponsorList renders its download button only when the URL is non-blank (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSponsorList.razor:29, :36, with the value loaded at PublicSponsorList.razor.cs:63) and the landing page guards its own call to action the same way (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor:315, :327). The safety reason is the scheme check described above. Wrapping the shared fragment rather than re-declaring the rules keeps the validator and the framework's URL invariant answering identically.
  • Where it's used: Included by EventFieldRules<T> on p => p.SponsorshipPacketUrl (EventValidationRules.cs:149), reaching both EventCreateRequestValidator and EventUpdateRequestValidator. The scheme rule is pinned by a theory that feeds it a scheme-less host and a data: payload (EventCreateRequestValidatorTests.cs:218-221), and the optional and overlong cases at :130 and :153.
  • Caveats / not-in-source: the wrapped base sets no WithErrorCode because this fragment passes none (CommonValidationRules.cs:87), so a client keying off error codes gets nothing for this field. Neither is there a domain-side backstop: no Ensure... method in the Conference module calls EnsureUrlIsWellFormed, so a caller that bypasses the validator can still persist a non-http value within the length bound, subject only to the EF column length (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Events/EventConfiguration.cs:65-66).

EventTicketingUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:97 · Level 7 · class (sealed, generic)

  • What it is: a rule fragment for the event's optional ticketing URL, bounded to EventInvariants.TicketingUrlMaxLength, 2000 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:44, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/EventDTO.cs:46) and required to be an absolute http or https URL when supplied. It is the exact twin of its sponsorship sibling, one field over.
  • Depends on: AbsoluteUrlRules<T> (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:85-92) and EventInvariants. It inherits AbstractValidator<T> directly.
  • Concept: the conditional-inclusion shape taught on EventOrganizerContactEmailRules<T> and the scheme check taught on EventSponsorshipPacketUrlRules<T>, repeated verbatim. Reading the three optional event fragments in a row (:57, :77, :97) is the clearest illustration of the module's convention: an optional field gets a compiled accessor, a When presence guard, and an Include of a shared framework fragment, and the only things that vary between them are which framework fragment is wrapped and which invariant constant bounds it. [Rubric §2, Design Patterns] assesses whether a recurring shape is expressed as a reusable composition rather than duplicated logic: the wrapping is identical, only the parameters change.
  • Walkthrough: the constructor takes an Expression<Func<T, string?>> selector (:100), compiles it to a delegate (:102), and registers When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new AbsoluteUrlRules<T>(selector, "Ticketing URL", EventInvariants.TicketingUrlMaxLength))) (:104-105). The shared base contributes the MaximumLength rule whose message is "Ticketing URL cannot be longer than 2000 characters" and the Must(BeAnAbsoluteHttpUrl) rule whose message is "Ticketing URL must be an absolute http or https URL" (CommonValidationRules.cs:87-89).
  • Why it's built this way: the doc comment states the product reason (:89-95): the field is optional, and an empty value means the landing page and the public event page hide the ticketing call to action. Both sites guard on the value being non-blank before rendering a button, the landing page at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor:71-75 and the public event page at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor:114-118, so a blank value is a supported product state rather than a validation failure. The 2000-character bound is the same constant the EF configuration applies to the column (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Events/EventConfiguration.cs:69-70).
  • Where it's used: Included by EventFieldRules<T> on p => p.TicketingUrl (EventValidationRules.cs:150), reaching both EventCreateRequestValidator and EventUpdateRequestValidator. A theory pins the rejection of a scheme-less host, a site-relative path, and a javascript: payload (EventCreateRequestValidatorTests.cs:205-209).
  • Caveats / not-in-source: as with the sponsorship URL, no WithErrorCode is attached because the wrapped base is given none. Note also that the landing page's pre-conference ticketing button is a separate, hard-coded constant (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor.cs:29, used at ADCHome.razor:154) and does not flow through this field or this rule.

EventTimeZoneRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:25 · Level 7 · class (sealed, generic)

  • What it is: a rule fragment for an event's time zone: non-empty, bounded by EventInvariants.TimeZoneMaxLength (100 characters, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:23), and semantically checked to be a time-zone identifier the runtime actually recognizes. The doc comment ties the third rule to business requirement BR-87 (EventValidationRules.cs:21-22).
  • Depends on: EventInvariants and System.TimeZoneInfo (BCL). It inherits AbstractValidator<T> directly.
  • Concept: the same fragment shape as EventNameRules<T>, but it needs a predicate beyond string length, so it extends AbstractValidator<T> and adds a Must(...) rule backed by a private static predicate method. [Rubric §24, Forms, Validation & UX Safety] assesses whether the boundary rejects values the downstream code cannot use: proving the string resolves to a real time zone stops an unusable identifier from reaching scheduling logic. [Rubric §15, Best Practices & Code Quality] assesses defensive detail: the predicate catches only TimeZoneNotFoundException (:44), so an unexpected failure surfaces as an exception rather than being silently reported as "invalid input".
  • Walkthrough: the constructor chains three rules on one selector (:28-32): NotEmpty with code Event.TimeZone.Required (:30), MaximumLength(EventInvariants.TimeZoneMaxLength) with code Event.TimeZone.MaxLength (:31), and Must(BeAValidIanaTimeZone) with code Event.TimeZone.InvalidIana and a message naming 'America/New_York' as the example form (:32). BeAValidIanaTimeZone (:34-48) returns true immediately for null or whitespace (:36-37), with an inline comment noting that NotEmpty already covers that branch, then calls TimeZoneInfo.FindSystemTimeZoneById(timeZone) inside a try (:39-43) and returns false only on TimeZoneNotFoundException (:44-47).
  • Why it's built this way: returning true for the empty case avoids emitting two messages for one missing field. Delegating the identifier check to TimeZoneInfo reuses the platform's canonical time-zone database instead of hand-maintaining a list of identifiers. The domain repeats all three checks in EventInvariants.EnsureTimeZoneIsValid (EventInvariants.cs:82-102, with the codes Event.TimeZone.Empty, Event.TimeZone.TooLong, and Event.TimeZone.Invalid), so a caller that bypasses the validator still cannot persist an unknown zone. Note that the application-layer codes and the domain-layer codes are deliberately different strings for the same three conditions.
  • Where it's used: Included by EventFieldRules<T> on p => p.TimeZone (EventValidationRules.cs:146), reaching both EventCreateRequestValidator and EventUpdateRequestValidator. The empty and valid-identifier cases are pinned at EventCreateRequestValidatorTests.cs:44 and :69.
  • Caveats / not-in-source: FindSystemTimeZoneById resolves against the host operating system's time-zone database, so which identifiers are accepted can differ between a Windows developer machine and the Linux containers the services run in. Nothing in the rule pins that behavior, and the message says "IANA" while the lookup is whatever the host supports.

RoomAccessibilityInfoRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:75 · Level 7 · class (sealed, generic)

  • What it is: a rule fragment bounding a room's optional accessibility-info text to EventInvariants.RoomAccessibilityInfoMaxLength, 500 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:56, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Rooms/RoomDTO.cs:25).
  • Depends on: OptionalStringRules<T>, its base class from the framework (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:53), and EventInvariants, which re-exports the constant from RoomDTO.
  • Concept: the same subclass-a-framework-fragment idiom taught on RoomSortRules<T>, applied to the optional-string case. Optionality is expressed in the base's own shape: OptionalStringRules<T> declares MaximumLength and nothing else, so a null value passes with no guard needed (CommonValidationRules.cs:55-57). [Rubric §15, Best Practices & Code Quality] assesses whether a constraint lives in exactly one place: the rule is written once in the framework, the bound once in the domain, and the module contributes only a label and a code.
  • Walkthrough: the class declares : OptionalStringRules<T> (RoomValidationRules.cs:76) and forwards base(selector, "Accessibility Info", EventInvariants.RoomAccessibilityInfoMaxLength, "Room.AccessibilityInfo.MaxLength") from a constructor taking an Expression<Func<T, string?>> (:78-79). The base emits the message "Accessibility Info cannot be longer than 500 characters" carrying the supplied code.
  • Why it's built this way: accessibility notes are free text an organizer may not have yet, so absence is valid; only the length is constrained, using the same constant the domain and the persistence configuration share. Passing the fourth errorCode argument is what keeps the field machine-addressable despite the rule being inherited.
  • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:16) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:16), both on p => p.AccessibilityInfo; the overlong case is pinned at MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/Validation/CommandValidatorTests.cs:161.

RoomFloorRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:51 · Level 7 · class (sealed, generic)

  • What it is: a rule fragment bounding a room's optional floor label to EventInvariants.RoomFloorMaxLength, 100 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:50, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Rooms/RoomDTO.cs:19).
  • Depends on: OptionalStringRules<T> (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:53) and EventInvariants.
  • Concept: structurally identical to RoomAccessibilityInfoRules<T>: a nullable string? selector, one inherited MaximumLength rule, no NotEmpty.
  • Walkthrough: : OptionalStringRules<T> (RoomValidationRules.cs:52) with the constructor forwarding base(selector, "Floor", EventInvariants.RoomFloorMaxLength, "Room.Floor.MaxLength") (:54-55).
  • Why it's built this way: a floor is a label ("2", "Mezzanine"), not a required attribute of a room, so the fragment constrains only its length.
  • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:14) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:14), both on p => p.Floor; the overlong case is pinned at CommandValidatorTests.cs:145.

RoomLocationRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:63 · Level 7 · class (sealed, generic)

  • What it is: a rule fragment bounding a room's optional location text to EventInvariants.RoomLocationMaxLength, 255 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:53, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Rooms/RoomDTO.cs:22).
  • Depends on: OptionalStringRules<T> (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:53) and EventInvariants.
  • Concept: structurally identical to RoomFloorRules<T>. Reading the three optional room fragments together (:51, :63, :75) shows why the family exists at all: each is two lines, and the only things that vary are the invariant constant, the message noun, and the error code. [Rubric §2, Design Patterns] assesses whether a recurring shape is expressed as a reusable composition: the shared base owns the rule, and each field owns only its naming.
  • Walkthrough: : OptionalStringRules<T> (RoomValidationRules.cs:64) with the constructor forwarding base(selector, "Location", EventInvariants.RoomLocationMaxLength, "Room.Location.MaxLength") (:66-67).
  • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:15) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:15), both on p => p.Location; the overlong case is pinned at CommandValidatorTests.cs:153.

RoomNameRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:13 · Level 7 · class (sealed, generic)

  • What it is: a rule fragment for a room's name: non-empty and bounded by EventInvariants.RoomNameMaxLength, 255 characters (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:47, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Rooms/RoomDTO.cs:16). It is the one required field among the room fragments.
  • Depends on: EventInvariants. It inherits AbstractValidator<T> directly rather than a framework base.
  • Concept: the same shape as EventNameRules<T>, but written out on AbstractValidator<T> rather than derived from RequiredStringRules<T>, even though the base would fit. Writing the chain locally buys two distinct error codes, one per bound, which the base cannot express: its single optional errorCode is applied to every rule it declares (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:11-18, :43-46), so a field whose bounds must answer under different codes still declares its own rules. [Rubric §9, API & Contract Design] assesses the stability of the error contract clients consume: Room.Name.Required and Room.Name.MaxLength are separately addressable, whereas the event name yields a message only.
  • Walkthrough: the constructor takes a single Expression<Func<T, string>> selector (:16) and chains NotEmpty with the message "You must enter a Room Name" and the code Room.Name.Required (:17-18), then MaximumLength(EventInvariants.RoomNameMaxLength) with the code Room.Name.MaxLength (:19).
  • Why it's built this way: the required name is what distinguishes this fragment from the three optional room fields; keeping each field as its own fragment lets a command validator compose exactly the mix it needs, which is what the two room validators do line by line.
  • Where it's used: Included by AddRoomCommandValidator (AddRoomCommandValidator.cs:11) and UpdateRoomCommandValidator (UpdateRoomCommandValidator.cs:11), both on p => p.Name; empty and overlong cases are pinned at CommandValidatorTests.cs:75, :84, and :129.
  • Caveats / not-in-source: the length rule duplicates a domain-side check: EventInvariants.EnsureRoomNameIsValid enforces the same constant with the codes Room.Name.Empty and Room.Name.TooLong (EventInvariants.cs:135-138). The application fragment gives a fast, field-attributed failure; the domain invariant is the backstop that also fires for callers that bypass the validator.

EventFieldRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Validation · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:139 · Level 8 · class (sealed, generic)

  • What it is: the composite that assembles the six event field fragments into one rule set, declared once over IEventFieldsRequest. It is what the create and update validators actually include; neither of them names an individual fragment.
  • Depends on: all six sibling fragments in the same file, EventNameRules<T>, EventTimeZoneRules<T>, EventDateRangeRules<T>, EventOrganizerContactEmailRules<T>, EventSponsorshipPacketUrlRules<T>, and EventTicketingUrlRules<T>, plus IEventFieldsRequest as its generic constraint and FluentValidation's AbstractValidator<T>.
  • Concept, the shared-rule-set-plus-delta pattern. The fragments below it answer "how is one field validated"; this class answers "what does an event request validate at all". Because it is generic and constrained to the interface (EventValidationRules.cs:140-141), it is instantiated once per request type and each concrete validator reduces to an Include plus whatever its own operation adds. That is a different arrangement from a shared base validator: composition through Include means the create path pays nothing for the update path's extra rule, and a reader of either validator sees the full delta in two or three lines. [Rubric §1, SOLID] assesses open/closed adherence and dependency inversion: adding a shared field means adding a line here and a member to the interface, never editing a concrete validator; the composite binds to the abstraction. [Rubric §5, Vertical Slice] assesses whether a slice owns its own behavior: the create and update slices each keep their own validator file and their own delta, while sharing this one declaration. [Rubric §15, Best Practices & Code Quality] assesses duplication: without a shared declaration, six Include lines have to be kept identical across two files by hand, which is exactly the state the room validators are still in.
  • Walkthrough: no fields, no parameters. The parameterless constructor (:143-151) issues six Include calls in field order: EventNameRules<T> on p => p.Name (:145), EventTimeZoneRules<T> on p => p.TimeZone (:146), EventDateRangeRules<T> on the p => p.StartDate, p => p.EndDate pair (:147), EventOrganizerContactEmailRules<T> on p => p.OrganizerContactEmail! (:148, the null-forgiving operator bridging the interface's string? to that fragment's non-nullable selector), EventSponsorshipPacketUrlRules<T> on p => p.SponsorshipPacketUrl (:149), and EventTicketingUrlRules<T> on p => p.TicketingUrl (:150). Every lambda binds against T's interface members, which is what the where T : IEventFieldsRequest constraint (:141) makes legal.
  • Why it's built this way: the class doc comment states the intent directly (:133-137): each concrete request validator includes this and adds only the rules its own operation needs. That is visible on both sides. EventCreateRequestValidator is a single expression-bodied constructor, => Include(new EventFieldRules<EventCreateRequest>()) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:9-10), and EventUpdateRequestValidator includes the same set and then adds its one update-only rule, IsInEnum() on the live-layer moderation default under the code Event.QuestionModerationDefault.Invalid (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:11-18, with the reason for the asymmetry in the comment at :13-14).
  • Where it's used: instantiated as EventFieldRules<EventCreateRequest> and EventFieldRules<EventUpdateRequest> by the two validators above, which the framework's validation decorator resolves in the CQRS pipeline (primer, 00-primer.md), so this rule set runs before either the create or the update handler executes. It is covered end to end by EventCreateRequestValidatorTests and EventUpdateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/Validation/EventCreateRequestValidatorTests.cs, MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/Validation/EventUpdateRequestValidatorTests.cs), which exercise it through the concrete validators rather than directly.
  • Caveats / not-in-source: the room fragments have no equivalent composite. AddRoomCommandValidator and UpdateRoomCommandValidator still repeat the same six Include lines each (AddRoomCommandValidator.cs:11-16, UpdateRoomCommandValidator.cs:11-16), because the two room commands share no interface the way the two event requests do.

BatchSessionQuestionAnswerItem

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.BatchAddSessionQuestionAnswers · MMCA.ADC.Conference.Application/Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersCommand.cs:9 · Level 0 · record (sealed)

  • What it is: one line of a session feedback form: the question being answered and the answer text. It is the element type of the batch command's Answers list and carries nothing else.
  • Depends on: the QuestionIdentifierType alias (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs) and a BCL string. No first-party types, nothing external.
  • Concept introduced: the batch element that is not the single-answer command. The single-answer path has its own message, AddSessionQuestionAnswerCommand; this record deliberately is not that type reused as a list element. It drops the session id (the batch command states it once for the whole form, :23) and it drops the command markers, because one element is not independently dispatchable. [Rubric §9, API & Contract Design] assesses whether a contract says exactly what the operation needs: hoisting SessionId out of the element makes it structurally impossible to submit a form whose rows target different sessions. [Rubric §6, CQRS & Event-Driven] assesses message shape: the dispatchable unit is the whole form, so only the enclosing record is a command.
  • Walkthrough: two positional members declared across three lines (:9-11), QuestionId and AnswerValue, documented individually above the declaration (:6-8). No body, no defaults, no validation of its own: the per-element rule (AnswerValue must be non-empty) is applied by BatchAddSessionQuestionAnswersCommandValidator through a RuleForEach child-rule block (BatchAddSessionQuestionAnswersCommandValidator.cs:22-26).
  • Why it's built this way: it lives in the same file as the command rather than in a file of its own because it has no independent life: nothing constructs one except the API mapping step and the tests. Declaring it as a positional record gives value equality and immutability with no ceremony, which is what lets the validator's duplicate-question check treat the list as plain data.
  • Where it's used: built by SessionQuestionAnswersController when it projects its own wire record BatchSessionQuestionAnswerItemRequest (MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:38-46) into command elements (:200-202), then read by BatchAddSessionQuestionAnswersHandler in both its validation pass (BatchAddSessionQuestionAnswersHandler.cs:89, :98) and its apply pass (:126).
  • Caveats / not-in-source: there are two near-identical types on this path, this one and the controller's BatchSessionQuestionAnswerItemRequest (SessionQuestionAnswersController.cs:49). Nothing in source keeps their shapes in step; the mapping at :200-202 is the only place the two meet.

GetCategoryDistributionQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionQuery.cs:5 · Level 0 · record (sealed)

  • What it is: the CQRS query contract that asks for the distribution of an event's sessions across its category items. A one-line record carrying nothing but the event to analyze.
  • Depends on: the EventIdentifierType alias, an int in this module (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). No other first-party types, nothing external.
  • Concept introduced: none new; this is a plain read-side CQRS request, and the query/handler split is taught by IQueryHandler<in TQuery, TResult>. What is worth noticing is that the record implements no marker interface: the pairing to a handler is purely the generic argument on IQueryHandler<GetCategoryDistributionQuery, Result<CategoryDistributionDTO>> (GetCategoryDistributionHandler.cs:15). [Rubric §6, CQRS & Event-Driven] assesses whether reads and writes travel separate paths with explicit contracts: this record is a read intent with no side effects, resolved by GetCategoryDistributionHandler.
  • Walkthrough: one positional parameter, EventId of type EventIdentifierType (:5), documented by the two-line summary above it (:3-4). No body, no defaults.
  • Why it's built this way: keeping the query as a standalone record means it can be dispatched on its own (an organizer opening the category-distribution view) or computed alongside the other decision-support dimensions by GetSessionSelectionDashboardHandler without one endpoint over-fetching for another.
  • Where it's used: constructed by SessionSelectionController on GET SessionSelection/categories/{eventId} (MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:53-65), which resolves the handler through the injected IQueryHandler<GetCategoryDistributionQuery, Result<CategoryDistributionDTO>> (:32).

GetContentSimilarityQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityQuery.cs:6 · Level 0 · record (sealed)

  • What it is: the read request behind "which pairs of submitted sessions look like the same talk". It carries the event to analyze plus the score floor below which a pair is not worth showing.
  • Depends on: the EventIdentifierType alias (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8) and a BCL double. No first-party types.
  • Concept introduced: the query that carries a tuning knob. The sibling decision-support queries are pure identity ("analyze this event"); this one also carries a policy value, MinimumSimilarity, with an in-contract default of 0.3 (:6). [Rubric §9, API & Contract Design] assesses whether a contract's optional inputs are explicit and defaulted in one place: here the default is stated twice, once on the record (:6) and once on the controller action parameter (MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:86), so an HTTP caller that omits minimumSimilarity never exercises the record's default at all. [Rubric §6, CQRS & Event-Driven] applies as for the sibling queries: a named read message resolved by exactly one IQueryHandler<in TQuery, TResult>.
  • Walkthrough: two positional members (:6): EventId, and MinimumSimilarity defaulted to 0.3. The doc comment documents the intended range as 0.0 to 1.0 (:5). The value is used exactly once, as an inclusive lower bound in GetContentSimilarityHandler (GetContentSimilarityHandler.cs:74, score >= query.MinimumSimilarity), which is what HandleAsync_TreatsMinimumSimilarityAsInclusiveLowerBound pins (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetContentSimilarityHandlerTests.cs:173).
  • Why it's built this way: similarity is a judgment call, not a fact: an organizer sweeping for near-duplicate submissions wants a different floor than one looking only at blatant overlaps. Putting the knob on the query rather than in configuration lets the caller choose per request without a redeploy.
  • Where it's used: constructed by SessionSelectionController on GET SessionSelection/content-similarity/{eventId} (SessionSelectionController.cs:81-94), binding minimumSimilarity from the query string (:86).
  • Caveats / not-in-source: the 0.0 to 1.0 range is documentation only. The query pipeline does run a ValidatingQueryDecorator<TQuery, TResult> (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:141), but that decorator is a pass-through when no IValidator<TQuery> is registered (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingQueryDecorator.cs:69-72), and no validator exists for this query: the GetContentSimilarity folder holds only the query, the handler, and SessionSimilarityCalculator. A caller passing 5.0 therefore gets an empty pair list, and a negative floor returns every pair up to the handler's cap, both silently.

LocalityLookupEntry

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelper.cs:13 · Level 0 · record class (internal sealed)

  • What it is: one entry of the merged speaker-locality lookup: a tier name plus the id of the locality category the item came from. It is declared in the same file as SpeakerLocalityHelper (SpeakerLocalityHelper.cs:13-15) because it is that helper's dictionary value type and nothing outside the helper constructs one.
  • Depends on: the ConferenceCategoryIdentifierType alias, an int in this module (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7). No other first-party types, nothing external beyond the BCL.
  • Concept introduced: locality modelled as a category assignment that carries its import generation. A speaker's origin is not a column on Speaker: it is a SpeakerCategoryItem row pointing at a CategoryItem inside a "Where are you traveling from" Category (SpeakerLocalityHelper.cs:17-19, with the title match at :98-99). [Rubric §4, DDD] assesses whether concepts are expressed through the aggregates the domain actually has rather than through bolted-on fields; here the answer is read out of the existing category machinery, which is also what the Sessionize import populates. [Rubric §8, Data Architecture] explains the second field: Category is a global aggregate with no event scoping, so every yearly Sessionize refresh adds another locality category carrying fresh item ids (:82-84), and a returning speaker keeps the item from every year they answered the question (:51-52). A bare id-to-name lookup cannot say which year an item belongs to; pairing the name with its owning category id can, and that is the whole reason this record exists.
  • Walkthrough: two positional members on a record class (:13-15). Name is the locality tier name, documented with the example "Atlanta and Suburbs" (:11); CategoryId is the identifier of the locality category owning the item (:12). The type is internal sealed, so it never leaves the Application assembly. Values are produced in exactly one place, BuildLocalityLookup, which walks the locality categories in ascending id order and writes lookup[item.Id] = new LocalityLookupEntry(item.Name, category.Id) for every non-deleted item (:128-136); they are consumed in exactly one place, GetLocalityTier, which walks the speaker's non-deleted assignments, looks each one up, and keeps the entry whose CategoryId is the highest seen so far (:43-58, the comparison at :53). Because the winner is chosen by comparison rather than by position, the order of the speaker's own assignments does not affect the answer.
  • Why it's built this way: the "most recent import wins" rule needs a tiebreaker that survives merging several years of categories into one dictionary, and the owning category id is the only ordering signal available without a schema change (:7-9, :117-119). Declaring it a record gives value equality and immutability for free, which is what lets the helper treat entries as plain values while scanning.
  • Where it's used: only inside the DecisionSupport folder, as the value type of the IReadOnlyDictionary<CategoryItemIdentifierType, LocalityLookupEntry> that SpeakerLocalityHelper builds and reads (:38, :123). That dictionary is threaded through two decision-support handlers: GetSessionSelectionDashboardHandler builds it once and passes it into its overlap, locality, and AI-score passes, and GetSpeakerSessionOverlapHandler does the same for its narrower view (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:52-53, :119).
  • Caveats / not-in-source: the "highest category id is the most recent import" rule is an assumption about how Sessionize allocates category ids. The source states it twice as a comment (:51-52, :117-119) but nothing enforces or validates it, so an out-of-order id would silently resolve a returning speaker to an older tier. The behavior is pinned by SpeakerLocalityHelperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs), which builds the entries directly and asserts that the newest-import tier wins regardless of assignment order, but the id ordering itself is an upstream property.

QuestionUpdateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Update · MMCA.ADC.Conference.Application/Questions/UseCases/Update/QuestionUpdateRequest.cs:4 · Level 0 · record class

  • What it is: the wire payload for editing an existing survey Question: its text, the entity it targets, its input type, its display order, and whether an answer is mandatory.
  • Depends on: nothing first-party. All five members are BCL primitives, and the record implements no interface (QuestionUpdateRequest.cs:4).
  • Concept introduced: a field that is in the contract but only conditionally editable. QuestionEntity (:10) and QuestionType (:13) are plain required string members here, so the wire contract accepts a new value for either. Whether that value is allowed is not a property of the record: UpdateQuestionHandler probes the three answer tables and rejects the change once any answer exists (BR-137, UpdateQuestionHandler.cs:41-75). Contrast this with a field removed from a request entirely, which is how the module expresses "never editable through this path". The distinction tells you where to look for a rule: a shape constraint lives in the record, a state-dependent constraint cannot, because the record has no access to the database. [Rubric §4, DDD] assesses whether rules live where the knowledge to enforce them lives. [Rubric §9, API & Contract Design] assesses contract uniformity: the payload stays the same between create and update, and the difference surfaces as a validation error with a stable code rather than as a missing property.
  • Walkthrough: QuestionText (:7), QuestionEntity (:10, documented as "Session" or "Event"), and QuestionType (:13, documented as "Rating", "Text", or "Email") are required string with init setters. Sort (:16) and IsRequired (:19) are plain value members that default to 0 and false. Note the near-miss in naming: IsRequired is the survey question's own "an attendee must answer this" flag, not the C# required modifier that governs three of its siblings. There is no RowVersion member: the concurrency token travels beside the payload on UpdateQuestionCommand (UpdateQuestionCommand.cs:14), read from the If-Match header rather than from the body.
  • Why it's built this way: the two discriminator strings are free-form string, not enums, so adding a question type or a new target entity does not require a change to the contract type; the legal values are asserted in the domain instead (QuestionInvariants, called from MMCA.ADC.Conference.Domain/Questions/Question.cs:116-118).
  • Where it's used: bound by QuestionsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/Questions/QuestionsController.cs:120), validated by QuestionUpdateRequestValidator, wrapped in UpdateQuestionCommand (:125), and consumed by UpdateQuestionHandler.

SessionSimilarityCalculator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/SessionSimilarityCalculator.cs:9 · Level 0 · class (internal static)

  • What it is: the pure-function core of the content-similarity feature: it turns two sessions into a single number between 0.0 and 1.0 by blending category-item overlap (weight 0.6) with keyword overlap from title and description (weight 0.4).
  • Depends on: System.Collections.Frozen.FrozenSet<string> and System.Linq from the BCL, plus the CategoryItemIdentifierType alias in one signature (:98-99). No first-party types at all: it never touches an entity, a repository, or a DTO.
  • Concept introduced: the Jaccard index, and why the scoring logic is a static class. The Jaccard index of two sets is the size of their intersection divided by the size of their union, so identical sets score 1.0 and disjoint sets score 0.0. This file applies it twice, once to the two sessions' category-item id sets and once to their keyword sets, then blends the two with fixed weights (:103-105). Two sessions tagged identically but sharing no vocabulary score 0.6; two sessions sharing vocabulary but no tags score 0.4. [Rubric §14, Testability] assesses whether logic can be exercised without infrastructure: because every method is static and takes plain sets, SessionSimilarityCalculatorTests drives all four of them with literal inputs and no test double at all (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SessionSimilarityCalculatorTests.cs:6). [Rubric §12, Performance & Scalability] assesses hot-path cost: this runs once per session pair, so the code avoids per-character allocation and uses an O(1) frozen set for stop-word lookups. [Rubric §1, SOLID] assesses separation of responsibility: the scoring rule lives apart from the handler that orchestrates loading and DTO building, so a weight change touches one file.
  • Walkthrough
    • CategoryWeight = 0.6 and KeywordWeight = 0.4 (:11-12), the only two tuning constants, private const and therefore not configurable at runtime.
    • StopWords, a FrozenSet<string> built once at type initialization with StringComparer.Ordinal (:14-34). It holds ordinary English function words and, deliberately, conference-generic vocabulary such as "SESSION", "TALK", "PRESENTATION", "DEEP", "DIVE", and "WORKSHOP" (:31-33), which would otherwise make every abstract look like every other abstract.
    • TokenizeText(string? text) (:41-71) returns an empty set for null or whitespace (:43-44), then scans the text as a ReadOnlySpan<char> with a manual index loop rather than string.Split (:48-68). A run of letters or digits ends at any other character or at end of input (:52); runs shorter than three characters are dropped (:62); surviving runs go to AddTokenIfNotStopWord (:123-130), which uppercases with ToUpperInvariant (the summary at :37 notes this is upper rather than lower to satisfy analyzer rule CA1308) and adds the token only if it is not a stop word.
    • CalculateJaccardIndex<T>(HashSet<T>, HashSet<T>) (:80-92) returns 0.0 when both sets are empty (:82-83), which is the guard against dividing by a zero union. It iterates the smaller set against the larger one's Contains (:85-88), then divides the intersection count by setA.Count + setB.Count - intersectionCount (:90-91).
    • CalculateSimilarity(...) (:97-106) is the blend: CategoryWeight * categoryScore + KeywordWeight * keywordScore.
    • GetIntersection<T>(...) (:115-121) returns the shared elements as a List<T>, again scanning the smaller set, and exists so the handler can show a reader why a pair scored what it scored.
  • Why it's built this way: a single signal is too blunt for program selection. Category overlap alone flags every pair inside a broad track; keyword overlap alone flags any two talks that both say "Kubernetes". Weighting categories higher than keywords encodes that a shared explicit tag is stronger evidence than shared prose. Keeping all of it internal static with no dependencies means the rule is auditable and unit-testable in isolation, which is what the test class does.
  • Where it's used: only by GetContentSimilarityHandler: TokenizeText while pre-computing per-session keyword sets (GetContentSimilarityHandler.cs:58), CalculateSimilarity inside the pairwise loop (:68-72), and GetIntersection twice when building each result row (:94-95).
  • Caveats / not-in-source: two sessions with no category items at all score 0.0 on the category component, not 1.0, because the both-empty case returns zero by design (:82-83): "neither is tagged" is treated as no evidence rather than as agreement. Stop words are English only, and the token filter keeps digits, so a version number such as "2026" counts as a keyword. The weights and the three-character minimum are constants with no configuration path.

StatusBucket

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94 · Level 0 · enum (private nested)

  • What it is: the three-way bucket a session's status collapses into while the category-distribution tally runs: Accepted, AcceptQueue, or Pending (GetCategoryDistributionHandler.cs:94-99).
  • Depends on: nothing. It is a bare enum with default backing type and no attributes, declared private inside GetCategoryDistributionHandler.
  • Concept introduced: the private classification enum that never leaves its handler. The persisted vocabulary is the string constants on SessionStatuses, and the wire vocabulary is the four integer counters on CategoryItemDistribution. This enum is neither: it exists only for the few lines between classifying a session and incrementing a counter, which is why it is private rather than a shared type. [Rubric §9, API & Contract Design] assesses what a module exposes: making the bucket private keeps it out of every contract, so renaming or adding a bucket is a one-file change with no consumer impact. [Rubric §15, Best Practices & Code Quality] assesses intent-revealing code: comparing bucket == StatusBucket.AcceptQueue inside the fold (:57-59) reads as a decision already made, and the compiler makes the three cases exhaustive in a way three loose booleans would not.
  • Walkthrough: three members in declaration order, Accepted, AcceptQueue, Pending (:96-98), so the implicit values are 0, 1, 2. Nothing depends on those numbers: the enum is only ever compared for equality. Values are produced in exactly one place, ClassifyStatus (:101-112), which maps a null status or SessionStatuses.Accepted to Accepted, SessionStatuses.AcceptQueue to AcceptQueue, and everything else to Pending, all with OrdinalIgnoreCase comparison. Declined sessions never reach it: CountSessionsPerCategoryItem filters them out first via IsDeclined (:44, :114-115), which is why there is no Declined member.
  • Why it's built this way: the distribution view answers one question per category item ("how many total, accepted, queued, still open"), so the many persisted statuses have to fold into exactly the four columns the DTO carries. Doing that fold once, into a named enum, keeps the string comparison in a single method instead of repeating string.Equals(..., OrdinalIgnoreCase) three times inside the counting loop.
  • Where it's used: only inside GetCategoryDistributionHandler.cs: returned by ClassifyStatus (:101), carried in the (CategoryItemId, Bucket) pairs that CountSessionsPerCategoryItem flattens each Session into (:47), and read three times when folding those pairs into the per-item tuple (:57-59).
  • Caveats / not-in-source: because a null status classifies as Accepted (:103-107), an unset session inflates the accepted bucket rather than the pending one, and the enum offers no member to represent "unknown". There is also no Declined member even though the persisted vocabulary has that status: the filter upstream is the only thing keeping a declined session from being counted as Pending.

QuestionUpdateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Update · MMCA.ADC.Conference.Application/Questions/UseCases/Update/QuestionUpdateRequestValidator.cs:7 · Level 8 · class (sealed)

  • What it is: the validator for QuestionUpdateRequest. One Include, covering the question text.
  • Depends on: AbstractValidator<T> (FluentValidation, QuestionUpdateRequestValidator.cs:1,7), QuestionUpdateRequest, and QuestionTextRules<T> from MMCA.ADC.Conference.Application.Questions.Validation (:2, :10).
  • Concept introduced: none new; it is the same one-line composition the module uses for every request validator. What is worth noticing is everything it does not validate. QuestionEntity and QuestionType are required strings with no rule here, even though only certain values are legal. Their legality is asserted twice further in: by QuestionInvariants inside Question.Update (MMCA.ADC.Conference.Domain/Questions/Question.cs:116-118), and, for the change rather than the value, by UpdateQuestionHandler's BR-137 probe. [Rubric §3, Clean Architecture] assesses whether each layer holds the checks it is entitled to hold: the validator owns cheap shape checks at the boundary, the aggregate owns the value invariants, and the handler owns the checks that require a database read.
  • Walkthrough: sealed class QuestionUpdateRequestValidator : AbstractValidator<QuestionUpdateRequest> (:7) with an expression-bodied constructor (:9-10) that includes new QuestionTextRules<QuestionUpdateRequest>(p => p.QuestionText). That rule set is NotEmpty plus MaximumLength(QuestionInvariants.QuestionTextMaxLength), carrying the codes Question.QuestionText.Required and Question.QuestionText.MaxLength (MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:16-18).
  • Why it's built this way: composing a shared rule object rather than restating RuleFor(x => x.QuestionText) in each validator means the create and update paths cannot drift on the same field: both take the identical QuestionTextRules<T> with a different type argument.
  • Where it's used: executed by the ValidatingCommandDecorator<TCommand, TResult> through UpdateQuestionCommand, ahead of UpdateQuestionHandler.

SpeakerLocalityHelper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelper.cs:21 · Level 8 · class (internal static)

  • What it is: the small pure helper that answers "where is this speaker traveling from?" by reading the speaker's category assignments. It finds the locality categories among all loaded categories, flattens their items into one lookup, and resolves a speaker to a single tier name such as "Atlanta and Suburbs" or "Not North America" (SpeakerLocalityHelper.cs:17-19).
  • Depends on: Category and CategoryItem (through Category.CategoryItems), Speaker and its SpeakerCategoryItem collection, LocalityLookupEntry as its dictionary value type, and the CategoryItemIdentifierType / ConferenceCategoryIdentifierType aliases (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7). Nothing external beyond the BCL: no repository, no IUnitOfWork, no logger.
  • Concept introduced: the pure in-memory helper beside a handler. Both decision-support handlers already load Category and Speaker graphs for other reasons, so the locality question is answered from data already in memory rather than by another query. Making the helper static with no injected dependencies means it is exercised directly in unit tests with hand-built aggregates and no test double at all. [Rubric §14, Testability] assesses whether logic can be tested without infrastructure: SpeakerLocalityHelperTests constructs speakers and categories in-process and asserts against all four public methods (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/SpeakerLocalityHelperTests.cs). [Rubric §12, Performance & Scalability] assesses repeated work: the expensive step (scanning every category's items) happens once per request in BuildLocalityLookup, and the per-speaker step is a dictionary probe. [Rubric §8, Data Architecture] assesses how the model's shape drives the code: because Category has no event scoping, correctness here depends on merging categories across imports rather than picking one.
  • Walkthrough, members in teaching order:
    • KnownLocalityCategoryId (:27), a private const holding 121854, the Sessionize identifier of the original "Where are you traveling from" category. It is the fallback used only when no category title matches the heuristic (:23-26).
    • FindLocalityCategories(IEnumerable<Category> categories) (:88) walks every category, skipping soft-deleted ones (:95-96), and collects those whose Title contains "traveling" or "Where are you based", case-insensitively (:98-102). A category that matches neither but carries the known id is remembered as fallback (:103-106). If nothing matched by title it returns the fallback as a single-element list, or an empty list (:109-110); otherwise it sorts the matches by ascending id and returns them (:112-113).
    • BuildLocalityLookup(IEnumerable<Category> localityCategories) (:123) iterates those categories in ascending id order (:128) and writes lookup[item.Id] = new LocalityLookupEntry(item.Name, category.Id) for every non-deleted item (:130-136). Ascending order is what makes the last write win, so a colliding item id resolves to the newest import (:116-119).
    • GetLocalityTier(Speaker speaker, IReadOnlyDictionary<CategoryItemIdentifierType, LocalityLookupEntry> localityCategoryItems) (:36-38) scans the speaker's SpeakerCategoryItems, skipping soft-deleted assignments (:45-46) and assignments whose item is not in the lookup (:48-49), and keeps the entry with the highest CategoryId seen (:53-57). It returns null when the speaker has no locality assignment at all (:40, :60).
    • IsLocalSpeaker(string? localityTier) (:69) returns false for null (:71-72) and otherwise reports whether the tier name contains "Atlanta", "Georgia", or "Surrounding", case-insensitively (:74-76).
  • Why it's built this way: the regression this shape exists to fix is spelled out in the doc comment at :82-84: returning only the first (oldest) matching category left every speaker new to the current event resolving to no tier, because each yearly Sessionize refresh creates a fresh locality category with fresh item ids. Merging every locality category into one lookup, and breaking ties by highest owning category id, makes both a newcomer and a returning speaker resolve to their current-year answer. The title heuristic with an id fallback keeps the code working across two different question wordings without a configuration entry.
  • Where it's used: GetSessionSelectionDashboardHandler calls FindLocalityCategories and BuildLocalityLookup once and GetLocalityTier in three projection passes; GetSpeakerSessionOverlapHandler does the same for its narrower view (MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:52-53, :119).
  • Caveats / not-in-source: two things are worth flagging. First, IsLocalSpeaker has no production caller: the only references are its declaration (:69) and the theory in SpeakerLocalityHelperTests, so the "local speaker" notion is defined and tested but not yet consumed by a handler. Second, the tier match is substring-based, so any future tier name containing "Georgia" or "Surrounding" would be classified local without a code change; nothing in source constrains the set of tier names Sessionize can produce.

UpdateQuestionCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Update · MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionCommand.cs:15 · Level 8 · record (sealed)

  • What it is: the write intent for updating a Question: route id, the QuestionUpdateRequest body, and the caller's optimistic-concurrency token, marked as cache-invalidating.
  • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (UpdateQuestionCommand.cs:15), QuestionUpdateRequest (:15), and the Question type for its FullName (:1, :18). QuestionIdentifierType is the module identifier alias.
  • Concept introduced: the concurrency token as a command member, not a body member. The token is a third positional parameter (:14) rather than a field on the request record, and its doc comment states both where it comes from and that it is not optional: the caller's last-observed version, read from the If-Match header, on an endpoint where "a conditional update that states no precondition never reaches this command" (:9-13). [Rubric §9, API & Contract Design] assesses where a precondition belongs: If-Match is an HTTP-level precondition, so it is lifted off the body by the controller and handed to the command as data, keeping the JSON payload free of transport concerns. [Rubric §8, Data Architecture] assesses concurrency control: this is the ADR-035 optimistic-concurrency contract (Website/docs-src/adr/035-optimistic-concurrency.md), enforced by the base handler rather than by this record.
  • Walkthrough: sealed record UpdateQuestionCommand(QuestionIdentifierType Id, QuestionUpdateRequest Request, byte[] RowVersion) with both interfaces on the declaration (:14), and one computed member, CachePrefix => $"{typeof(Question).FullName}:" (:17). The prefix is what the cache-invalidating decorator uses to evict every cached read of the question aggregate after a successful write.
  • Why it's built this way: the type is byte[], not string, because that is the shape EF Core stamps back onto the tracked entity; encoding and decoding the ETag is the API layer's job (SupportsIfMatchAttribute). Declaring the parameter non-nullable states the endpoint's contract in the type system: an unconditional caller is rejected at the boundary with 428, never here.
  • Where it's used: constructed by QuestionsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/Questions/QuestionsController.cs:126), with the token pulled from the request by SupportsIfMatchAttribute.RequiredToken(HttpContext) (:122), against the injected ICommandHandler<UpdateQuestionCommand, Result<QuestionDTO>> (:37), and handled by UpdateQuestionHandler.

BatchAddSessionQuestionAnswersCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.BatchAddSessionQuestionAnswers · MMCA.ADC.Conference.Application/Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersCommand.cs:22 · Level 9 · record (sealed)

  • What it is: the write intent for submitting a whole session feedback form in one request: the session being answered plus the list of answers, one per question.
  • Depends on: BatchSessionQuestionAnswerItem as its element type (:24), ICacheInvalidating and ITransactional (:2, :24), the Session type for its FullName (:1, :27), and the SessionIdentifierType alias.
  • Concept introduced: ITransactional as an all-or-nothing declaration on the message. The module's decorator pipeline reads markers off the command type rather than off the handler: TransactionalCommandDecorator<TCommand, TResult> is registered innermost of the command decorators (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:131) and opens a transaction only for commands carrying ITransactional. Adding the marker here is therefore the entire mechanism by which a partially applied form becomes unobservable, and the doc comment says exactly that (:17-18). [Rubric §6, CQRS & Event-Driven] assesses whether cross-cutting behavior is declared on the message and applied by the pipeline rather than hand-rolled in each handler: the handler contains no transaction code at all. [Rubric §8, Data Architecture] assesses write atomicity: one transaction plus one SaveChangesAsync means an accepted form is one durable unit, which also keeps the outbox rows this write produces in the same commit (ADR-003). [Rubric §12, Performance & Scalability] assesses request economy: the doc comment records the batch's real payoff, which is that the session, its owning event, and every referenced question are read once instead of once per answer (:15-17).
  • Walkthrough: two positional members (:22-24), SessionId and Answers typed as IReadOnlyList<BatchSessionQuestionAnswerItem>, with both markers on the declaration (:24) and one computed member, CachePrefix => $"{typeof(Session).FullName}:" (:27), which evicts the session aggregate's cached reads after a successful write. The class-level summary is where the semantics live: every answer is upserted the same way the single-answer command does (BR-107), and the form is accepted or refused together (:13-19).
  • Why it's built this way: the alternative, having the client loop the single-answer endpoint, gives the same rows but not the same guarantees: a network failure halfway through leaves a half-saved form the client has to reconcile, and every call repeats the session, event, and question reads. Declaring the whole form as one message moves both problems into the pipeline (ADR-014).
  • Where it's used: constructed by SessionQuestionAnswersController on POST SessionQuestionAnswers/batch (MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:192-210, the command built at :200-202) against the injected ICommandHandler<BatchAddSessionQuestionAnswersCommand, Result<IReadOnlyList<SessionQuestionAnswerDTO>>> (:79), validated by BatchAddSessionQuestionAnswersCommandValidator, and handled by BatchAddSessionQuestionAnswersHandler.
  • Caveats / not-in-source: the endpoint also carries IdempotentAttribute (SessionQuestionAnswersController.cs:193), so a retry with the same Idempotency-Key replays the first response (ADR-017) rather than re-applying the form. That replay contract lives on the action, not on this record: dispatching the same command twice through the handler directly would run the BR-107 upsert twice.

GetCategoryDistributionHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetCategoryDistribution · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:14 · Level 9 · class (sealed)

  • What it is: the query handler that computes, per category item, how many of an event's sessions were submitted, accepted, put in the accept queue, or left pending. It backs the organizer's category-distribution view during session selection.
  • Depends on: IQueryHandler<in TQuery, TResult> (implemented) and IUnitOfWork (the only constructor dependency, :14-15); Session and Category as repository entities plus CategoryItem through their collections; SessionStatuses for the status constants; the nested StatusBucket enum; and the output contracts CategoryDistributionDTO, CategoryGroupDistribution, and CategoryItemDistribution from MMCA.ADC.Conference.Shared.Sessions.DecisionSupport (:3).
  • Concept introduced: the in-memory analytics read handler. It loads two aggregate sets untracked and then does all filtering, bucketing, and grouping in C#, rather than pushing aggregation down into SQL. [Rubric §6, CQRS & Event-Driven] assesses the read path: this is a pure query returning Result<CategoryDistributionDTO> and mutating nothing, so it is wrapped only by the query-side decorators the framework registers (Timeout, Validating, Caching, Logging, Authorization, FeatureGate, MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:140-145), never by the command-side Transactional one (:129). [Rubric §12, Performance & Scalability] assesses read efficiency: both loads pass asTracking: false (:27, :32) so EF skips change tracking, and the tally is a single pass into a dictionary rather than a nested scan (:50-61). [Rubric §5, Vertical Slice] assesses feature cohesion: query, handler, and private bucketing live in one GetCategoryDistribution folder, so the whole feature is readable in one place.
  • Walkthrough: HandleAsync (:17-39) resolves a Session repository and a Category repository from the unit of work (:21-22). It loads the event's sessions with SessionCategoryItems included, filtered to s.EventId == query.EventId && !s.IsServiceSession (:24-28), then loads every category with its CategoryItems (:30-33), with no event filter because categories are global. CountSessionsPerCategoryItem (:41-64) drops declined sessions via IsDeclined (:45, :114-115), flattens each remaining session into (CategoryItemId, StatusBucket) pairs while skipping soft-deleted links (:46-48), and folds those pairs into a (Total, Accepted, AcceptQueue, Pending) tuple per category item (:50-61). ClassifyStatus (:101-112) treats a null status or SessionStatuses.Accepted as Accepted, SessionStatuses.AcceptQueue as AcceptQueue, and anything else as Pending, all with OrdinalIgnoreCase comparison. BuildCategoryGroups (:66-92) keeps only non-deleted categories that have at least one counted item (:70), orders categories by Sort (:71) and items by Sort (:78), and projects each item into a CategoryItemDistribution with its four counts, using TryGetValue so an uncounted item yields zeros (:81-90). The handler returns Result.Success(new CategoryDistributionDTO { Categories = categoryGroups }) (:38): it has no failure path.
  • Why it's built this way: aggregating in memory keeps the handler engine-agnostic (the same code runs against whatever store backs IUnitOfWork, per the database-per-service model of ADR-006) and states the domain rules (service sessions excluded, declined excluded, soft-deletes excluded at both the session-link and category-item levels) as readable filters instead of burying them in SQL. The trade-off is that a full event's sessions and the full category set are materialized; that is bounded by one conference's proposal volume.
  • Where it's used: injected into SessionSelectionController as IQueryHandler<GetCategoryDistributionQuery, Result<CategoryDistributionDTO>> (MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:32) and invoked from GET SessionSelection/categories/{eventId} (:53-65), an endpoint gated on the SessionSelectionManage permission at the controller level (:29) and output-cached under the ConferenceCache policy (:55). Behavior is pinned by GetCategoryDistributionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetCategoryDistributionHandlerTests.cs:13).
  • Caveats / not-in-source: a null Session.Status is counted as Accepted (:103-107), so a session whose status was never set inflates the accepted column rather than the pending one. The branch is explicit in code, but the reason for choosing Accepted over Pending as the null default is not stated there. The handler also resolves the read-write GetRepository (:21-22) although it only reads: IUnitOfWork exposes a narrower GetReadRepository alongside it (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:19 versus :29).

GetContentSimilarityHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetContentSimilarity · MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetContentSimilarity/GetContentSimilarityHandler.cs:14 · Level 9 · class (sealed)

  • What it is: the query handler that scores every pair of an event's live sessions for content overlap and returns the strongest pairs, each annotated with the tags and words the two proposals share.
  • Depends on: IQueryHandler<in TQuery, TResult> (implemented) and IUnitOfWork (the only constructor dependency, :14-15); SessionSimilarityCalculator for the scoring; Session, Category, and CategoryItem as loaded aggregates; SessionStatuses for the declined filter; and the output contracts ContentSimilarityDTO and SimilarSessionPair (:3).
  • Concept introduced: the bounded quadratic analytical query. Unlike its sibling handlers, which fold each session once, this one compares every session against every other one: a double loop with j = i + 1 (:64-79), so a 200-proposal event performs 19,900 comparisons. Three separate mechanisms keep that honest. First, the expensive per-session work (tokenizing title plus description, materializing the category-item id set) is hoisted out of the loop and done once per session (:51-59), so the loop body is only set arithmetic. Second, only sessions that can still be scheduled are loaded at all: the where predicate excludes service sessions and declined ones in the database (:29-31). Third, the result is capped at MaxPairs = 50 (:17, :83-86). [Rubric §12, Performance & Scalability] assesses exactly this shape: quadratic work is acceptable here because n is one conference's proposal count and the constant factor is a hash-set intersection, but the cost is real and the cap bounds the response, not the computation. [Rubric §9, API & Contract Design] assesses response determinism, which is why the sort is not a plain score sort (see the walkthrough). [Rubric §6, CQRS & Event-Driven] applies as for the sibling handlers: a pure read wrapped only by the query-side decorators.
  • Walkthrough: HandleAsync (:19-114) resolves Session and Category repositories (:23-24), loads the event's non-service, non-declined sessions with SessionCategoryItems included and asTracking: false (:27-33), and loads all categories with their items (:36-39) purely to build an id-to-name lookup for the shared-tag labels (:41-48). It then projects each session into an anonymous value of (Session, CategoryItems, Keywords) (:51-59), where CategoryItems skips soft-deleted links (:56) and Keywords comes from SessionSimilarityCalculator.TokenizeText(s.Title + " " + s.Description) (:58). The pairwise loop scores each combination and keeps the pair when score >= query.MinimumSimilarity (:64-79): the bound is inclusive, which is what HandleAsync_TreatsMinimumSimilarityAsInclusiveLowerBound pins (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetContentSimilarityHandlerTests.cs:173). Sorting uses the explicit comparer CompareByScoreThenIndex (:82, :120-132): score descending, then index A, then index B. The tie-break is the load-bearing part, and the summary above the comparer says why (:116-119): a plain score comparison leaves equal-scoring pairs in unspecified relative order, so truncating to 50 could return a different 50 for the same input (HandleAsync_WithTiedScores_TruncatesToADeterministicTopFifty, GetContentSimilarityHandlerTests.cs:319). Truncation is GetRange(0, MaxPairs) (:83-86). Each surviving pair becomes a SimilarSessionPair (:89-111) carrying both sessions' id, title, and status, the score rounded to three decimals with MidpointRounding.ToEven (:105), the shared category items resolved to names (:106-108), and at most ten shared keywords (:109). The method ends with Result.Success(new ContentSimilarityDTO { Pairs = result }) (:113) and has no failure path.
  • Why it's built this way: the point of the feature is a conversation between organizers, so the output has to be explainable: showing the shared tags and words next to the number is what makes a pair actionable rather than merely flagged. The deterministic comparer exists because the response is output-cached and read by humans comparing runs, so a stable list is worth the extra comparisons. Doing the whole computation in memory keeps the scoring rule in C# where it is unit-testable, rather than in SQL where it would not be.
  • Where it's used: injected into SessionSelectionController as IQueryHandler<GetContentSimilarityQuery, Result<ContentSimilarityDTO>> (MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:34) and invoked from GET SessionSelection/content-similarity/{eventId} (:81-94). The dashboard path does not use it: GetSessionSelectionDashboardHandler computes distribution, overlap, locality, and AI scores but no similarity block, so this handler is reachable only through its own endpoint.
  • Caveats / not-in-source: the 50-pair cap truncates silently: the response carries no flag saying more pairs cleared the threshold. Sessions with neither tags nor keywords score 0.0 against each other and are therefore returned when the caller passes a floor of 0.0 (GetContentSimilarityHandlerTests.cs:207). The declined filter is expressed as s.Status != SessionStatuses.Declined (:31), a comparison translated to SQL, whereas the sibling handlers compare statuses with OrdinalIgnoreCase in memory; whether the two agree on casing depends on the database collation, which this file does not state.

BatchAddSessionQuestionAnswersCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.BatchAddSessionQuestionAnswers · MMCA.ADC.Conference.Application/Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersCommandValidator.cs:10 · Level 10 · class (sealed)

  • What it is: the boundary validator for BatchAddSessionQuestionAnswersCommand. Three rules: the form has at least one answer, no question is answered twice in the same request, and no answer value is blank.
  • Depends on: AbstractValidator<T> (FluentValidation, :1, :10), BatchAddSessionQuestionAnswersCommand, and BatchSessionQuestionAnswerItem through the child rules. Nothing else.
  • Concept introduced: the collection-shape rule that protects an upsert. The interesting rule is the second one (:18-20): it fails the request when the answers contain a duplicate QuestionId. That check exists because the handler applies BR-107 as an upsert keyed on (CreatedBy, QuestionId, SessionId) (BatchAddSessionQuestionAnswersHandler.cs:128-130), and two rows targeting the same question inside one request would make the upsert fight itself: the second row would find the first row's freshly added answer or silently overwrite it, depending on tracking order. Rejecting the shape up front is cheaper and far more explainable than handling that inside the loop. [Rubric §15, Best Practices & Code Quality] assesses whether a constraint is expressed where it is cheapest to check: this is a pure function of the payload, so it belongs in the validator, not the handler. [Rubric §6, CQRS & Event-Driven] assesses pipeline placement: the rule runs in ValidatingCommandDecorator<TCommand, TResult>, which sits outside the transaction (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:131-133), so a malformed form never opens one.
  • Walkthrough: the constructor holds three registrations (:12-27). RuleFor(x => x.Answers).NotEmpty() with the message "At least one answer is required." (:14-16). RuleFor(x => x.Answers).Must(...) comparing answers.Select(a => a.QuestionId).ToHashSet().Count against answers.Count, null-tolerant, with the message "Each question may be answered only once per request." (:18-20). RuleForEach(x => x.Answers).ChildRules(...) applying NotEmpty() to each AnswerValue with the message "Answer value is required." (:22-26). All four behaviors are pinned by BatchAddSessionQuestionAnswersCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:39-84), including the blank-answer case, which uses a whitespace-only string (:80) because FluentValidation's NotEmpty treats whitespace as empty.
  • Why it's built this way: the class summary states the design intent explicitly (:5-9): apply the same non-empty answer rule the single-answer validator applies, plus the batch-only shape rules. Keeping the per-answer rule identical between the two surfaces is what makes the batch endpoint a pure optimization rather than a second, subtly different contract.
  • Where it's used: resolved by the validating decorator from the DI container as an IValidator<BatchAddSessionQuestionAnswersCommand> and run ahead of BatchAddSessionQuestionAnswersHandler. It is never invoked directly by the handler or the controller.
  • Caveats / not-in-source: the duplicate check runs on the identifier only, so two answers to different questions with identical text pass, as they should. It says nothing about whether the questions exist or target sessions: that is BR-128, checked by the handler against the database (BatchAddSessionQuestionAnswersHandler.cs:100-103).

BatchAddSessionQuestionAnswersHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.BatchAddSessionQuestionAnswers · MMCA.ADC.Conference.Application/Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersHandler.cs:22 · Level 10 · class (sealed partial)

  • What it is: the handler that applies a whole session feedback form in one request. It loads the session once, checks the session-level rules once and each answer against its question, upserts every answer onto the tracked aggregate, and commits the lot with a single SaveChangesAsync.
  • Depends on: ICommandHandler<in TCommand, TResult> (implemented, :27), IUnitOfWork (:22), ICurrentUserService (:23), SessionQuestionAnswerDTOMapper (:24), TimeProvider and ILogger<T> from the BCL and Microsoft.Extensions (:25-26); the aggregates Session, Event, and Question plus the SessionQuestionAnswer child (:3-5); SessionQuestionAnswerRules for the shared business rules; the SessionFeedbackSubmitted domain event (:7); Result and Error (:10); and SessionQuestionAnswerDTO as the response element.
  • Concept introduced: the batch handler that must not become a second contract. The temptation with a batch endpoint is to write a faster, looser version of the single-answer path. This handler is built to make that impossible: every rule it enforces is called out of SessionQuestionAnswerRules, the same static class the single-answer handler calls (MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerRules.cs:14, used here at :82-83 and :100-103, and in AddSessionQuestionAnswerHandler.cs:70, :80). That class exists precisely so the two surfaces share one definition of BR-91, BR-49, BR-108, BR-128 and BR-124 (SessionQuestionAnswerRules.cs:8-13), and both of its methods take the already-loaded Event and Question as parameters rather than looking them up, which is what lets the batch path read each of them once (SessionQuestionAnswerRules.cs:17-19, :50-51). [Rubric §1, SOLID] assesses whether a rule has one owner: the rules are neither duplicated nor subclassed, they are passed their inputs. [Rubric §5, Vertical Slice] assesses whether a slice can be added without disturbing its neighbor: the batch slice is its own folder and reuses the shared rules rather than editing the single-answer slice. [Rubric §12, Performance & Scalability] assesses N+1 avoidance: the referenced questions are read in one GetAllAsync with a Contains predicate (:91-95) and indexed into a dictionary (:96), so a 12-question form issues three reads, not 14. [Rubric §6, CQRS & Event-Driven] assesses event emission: the cross-module notification is raised on the aggregate, per newly created answer, and delivered through the outbox on the same commit (ADR-003).
  • Walkthrough, three methods in the order they run:
    • HandleAsync (:30-68) guards the command (:34), resolves the Session repository and loads the session with its SessionQuestionAnswers included and asTracking: true (:36-41), because the upsert mutates the loaded graph. A missing session returns Error.NotFound decorated with source and target (:42-47). It then calls ValidateAsync (:49-53) and Apply (:55-59), propagating either failure without writing, destructures the applied answers and the created count (:61), issues the single SaveChangesAsync (:63), logs (:64), and maps each applied answer to a DTO (:66-67).
    • ValidateAsync (:75-111) loads the session's owning Event (:80-81) and runs SessionQuestionAnswerRules.EnsureSessionAcceptsFeedback once for the whole form (:82-87), which covers BR-91 (no service sessions), BR-49 (eligible status) and BR-108 (published parent event, with a NotFound when the event could not be read, SessionQuestionAnswerRules.cs:41-46). It then collects the distinct question ids (:89), reads them all through the read-only repository untracked (:90-95), builds a dictionary (:96), and checks each answer with EnsureAnswerIsValid (:98-108), which covers BR-128 (the question exists and targets "Session") and BR-124 (the value matches the question type). The first failure returns immediately, so the whole form is refused on one bad row.
    • Apply (:118-161) reads the current user id (:122) and walks the answers. For each one it looks for a live answer by the same user to the same question (:129-130, the BR-107 key) and, when found, calls session.UpdateSessionQuestionAnswer(existing.Id, answer.AnswerValue) (:134); otherwise it calls session.AddSessionQuestionAnswer(null, answer.QuestionId, answer.AnswerValue) (:144) and raises one SessionFeedbackSubmitted on the aggregate carrying the user, session, event, and the TimeProvider-supplied timestamp (:155-156), incrementing createdCount (:157). Either domain failure aborts the whole apply pass (:135-138, :145-148). The method deliberately persists nothing; the comment at :115-116 records that the caller owns the single save.
    • LogAnswersSubmitted (:163-164) is the [LoggerMessage] partial, logging the session id, the applied count, and the newly created count.
  • Why it's built this way: raising the feedback event only on the create path (:152-154) is the rule that keeps Engagement's points award honest: editing an answer is not new feedback, so re-submitting a form awards nothing extra, and the batch path emits exactly what the single-answer path emits when the same form is submitted one call at a time. Splitting validation and application into two passes means no answer is ever written before every answer has been checked, which is what makes the ITransactional marker on the command mostly a belt-and-braces guarantee rather than the only one.
  • Where it's used: injected into SessionQuestionAnswersController as ICommandHandler<BatchAddSessionQuestionAnswersCommand, Result<IReadOnlyList<SessionQuestionAnswerDTO>>> (MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:79) and invoked from POST SessionQuestionAnswers/batch (:192-210). Behavior is pinned by BatchAddSessionQuestionAnswersHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersHandlerTests.cs:25), whose names state the contract directly: the form is refused and nothing is written when the session is missing (:138), when the parent event is unpublished (:155), when a question does not target sessions (:180), and when an answer does not match its question type (:205); a valid form creates every answer in one save (:231); the feedback event is raised once per created answer (:261); and an existing answer is updated without raising it (:293).
  • Caveats / not-in-source: currentUserService.UserId!.Value is dereferenced unconditionally (:122), so an unauthenticated invocation would throw rather than return a failure; the endpoint's [Authorize] (SessionQuestionAnswersController.cs:75) is what keeps that unreachable in production. The handler resolves the read-write GetRepository for the Event it only reads (:80), unlike the questions read which uses GetReadRepository (:90).

UpdateQuestionHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Update · MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:19 · Level 14 · class (sealed partial)

  • What it is: the handler for UpdateQuestionCommand. It rides the framework's shared load-mutate-save workflow and inserts one extra gate: a question's type and target entity may not change once anybody has answered it (BR-137).
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> in its four-parameter DTO flavor (:23), IUnitOfWork (:20), QuestionDTOMapper (:21), ILogger<T> (:22), Result and Error (:10), the Question aggregate (:4), and the three answer entities EventQuestionAnswer (:3), SessionQuestionAnswer (:5), and SpeakerQuestionAnswer (:6).
  • Concept introduced: the state-dependent immutability check, and how a handler contributes it to a template-method workflow. Two ideas meet here. The first is the rule itself: some constraints cannot live in the request record (it has no data) or in the aggregate (a Question does not hold its answers; those live in separate tables reached through their own repositories). BR-137 is one of them, because changing QuestionType from "Rating" to "Text" after answers exist would leave stored answers that no longer make sense under the new type. The second is the shape: this handler declares no HandleAsync at all. The load, the NotFound, the concurrency stamp, the save, and the DTO mapping are all supplied by MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:271-309) and its DTO-returning subclass (:342-360); the handler contributes four small overrides. [Rubric §2, Design Patterns] assesses the template method: the base fixes the algorithm and names the variation points, so a new update handler is four overrides rather than forty lines of repeated orchestration. [Rubric §4, DDD] assesses where knowledge lives: BR-137 is a cross-aggregate rule, and the application layer is the only place that can see both sides of it. [Rubric §12, Performance & Scalability]: the probe block is guarded behind a cheap comparison (:41-42), so the common edit (fixing a typo, reordering) costs zero extra queries, and the three ExistsAsync calls short-circuit as soon as one returns true. [Rubric §1, SOLID]: the aggregate stays ignorant of collections it does not own.
  • Walkthrough: sealed partial class with primary-constructor DI, passing unitOfWork and dtoMapper up to the base (:19-23). Four overrides follow.
    • EntityId (:26) returns command.Id, telling the base which aggregate to load.
    • RowVersion (:32) returns command.RowVersion, which is what opts this handler into optimistic concurrency: the base stamps the token as the entity's original row version when it is non-empty (MutateEntityHandlerBase.cs:291-292), so a concurrent edit surfaces as a DbUpdateConcurrencyException and a 412 instead of last-write-wins. The comment above the override records that intent (:28-29), and the base's default returns null for unconditional endpoints (MutateEntityHandlerBase.cs:91).
    • MutateAsync (:35-83) is the domain step, called by the base with the loaded, tracked aggregate. The BR-137 block fires only when entity.QuestionType != command.Request.QuestionType || entity.QuestionEntity != command.Request.QuestionEntity (:41-42). It probes event answers (:44-47), then session answers if still clean (:51-54), then speaker answers (:61-64, with a comment recording that speaker answers count the same as the other two), each through UnitOfWork.GetReadRepository<...>() (the read-only face declared at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:29), which both states the intent and keeps those entities out of the change tracker. On a hit it returns Error.Validation with code Question.ImmutableAfterAnswers, an explanatory message, and source and target (:67-74). Past the gate it returns entity.Update(questionText, questionEntity, questionType, sort, isRequired) (:77-82), which re-runs the value invariants and raises QuestionChanged (MMCA.ADC.Conference.Domain/Questions/Question.cs:108, :116-118, :128). A failure here short-circuits before the base's save, so a refused invariant never writes (MutateEntityHandlerBase.cs:294-296).
    • LogMutated (:86-87) is the post-save logging hook, calling the [LoggerMessage] partial LogQuestionUpdated declared at :89-90. The base calls it only after a successful save (MutateEntityHandlerBase.cs:305).
  • Why it's built this way: the refusal is an Error.Validation with a stable code rather than a thrown exception, so it travels the same Result channel as a FluentValidation failure and the controller's shared HandleFailure turns it into a client-error response with no special case (MMCA.ADC.Conference.API/Controllers/Questions/QuestionsController.cs:128-129). Overriding MutateAsync rather than HandleAsync is what keeps the ADR-035 concurrency stamp and the DTO mapping identical across every update handler in the module.
  • Where it's used: injected into QuestionsController as ICommandHandler<UpdateQuestionCommand, Result<QuestionDTO>> (MMCA.ADC.Conference.API/Controllers/Questions/QuestionsController.cs:38) and invoked on PUT {id} (:112-132), after which the controller evicts the conference:questions output-cache tag (:131).
  • Caveats / not-in-source: the BR-137 check is a read followed by a write with no lock between them, so an answer submitted in the window between the probe and the save is not caught. The row version protects the question row, not the answer tables. Whether that race has ever occurred in practice is not determinable from source.

GetSessionSelectionDashboardQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardQuery.cs:5 · Level 0 · record (sealed)

  • What it is - the read request for the whole session-selection screen in one call: summary counts, category distribution, speaker overlap, speaker locality, and AI scores for one event.
  • Depends on - the EventIdentifierType alias, an int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). Nothing else.
  • Concept introduced - the composite (screen-shaped) query. The other decision-support queries each answer one question; this one is deliberately shaped like the UI page rather than like a single analytical question, and its handler computes every dimension from one set of loads (GetSessionSelectionDashboardHandler.cs:12-15). [Rubric §9 - API & Contract Design] assesses whether the contract fits its consumer: one round trip for a screen that would otherwise need several, at the cost of a response the narrower endpoints do not need. [Rubric §12 - Performance & Scalability] assesses request economy: the sessions, categories, and speakers are read once and reused across every computed block instead of once per block. The query/handler split itself is taught by IQueryHandler<in TQuery, TResult> and is cross-referenced rather than re-taught here.
  • Walkthrough - one positional parameter, EventId of type EventIdentifierType (:5), with the summary and <param> doc above it (:3-4). No body, no defaults, no marker interface: the pairing to a handler is purely the generic argument on IQueryHandler<GetSessionSelectionDashboardQuery, Result<SessionSelectionDashboardDTO>> (GetSessionSelectionDashboardHandler.cs:17).
  • Why it's built this way - the Blazor page is the only consumer that needs all the dimensions, and it needs them consistent with each other. Answering them from one snapshot of loaded data means the counts on the page cannot disagree between panels.
  • Where it's used - constructed by SessionSelectionController on GET SessionSelection/dashboard/{eventId} (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:39-51), resolved through the injected IQueryHandler<GetSessionSelectionDashboardQuery, Result<SessionSelectionDashboardDTO>> (:31). That endpoint is the one the UI actually calls: SessionSelectionService requests sessionselection/dashboard/{eventId} and deserializes SessionSelectionDashboardDTO (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sessions/Selection/SessionSelectionService.cs:14-32, the url at :26), and calls none of the narrow decision-support endpoints.

GetSpeakerSessionOverlapQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapQuery.cs:5 · Level 0 · record (sealed)

  • What it is - the read request for the speaker-centric view of an event's submissions: who submitted what, with the multi-session speakers surfaced first.
  • Depends on - the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). Nothing else.
  • Concept - none new; it is the same one-field read message as GetSessionSelectionDashboardQuery, which teaches the shape. [Rubric §5 - Vertical Slice] assesses feature cohesion: this record sits in the same GetSpeakerSessionOverlap folder as its handler, so the whole capability is one directory.
  • Walkthrough - one positional parameter, EventId (:5); the summary above it states the intent as "find speakers with multiple submitted sessions" (:3). Note that the handler's own doc comment corrects that scope: it returns EVERY speaker with at least one submitted session (GetSpeakerSessionOverlapHandler.cs:11-17).
  • Why it's built this way - speaker overlap is a distinct selection concern from topic balance (one speaker holding three accepted slots is a program problem even when the topic mix is fine), so it gets its own message and its own endpoint rather than being a filter over the category view.
  • Where it's used - constructed by SessionSelectionController on GET SessionSelection/speaker-overlap/{eventId} (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:67-79), through the injected IQueryHandler<GetSpeakerSessionOverlapQuery, Result<SpeakerSessionOverlapDTO>> (:33).

ScoreEventSessionsCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsCommand.cs:5 · Level 0 · record (sealed)

  • What it is - the write message that says "score every non-service session on this event with the AI model". One positional EventId and nothing else.
  • Depends on - the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). Nothing external.
  • Concept introduced - the command nobody sends from a request thread. Unlike the other commands in this group, no controller ever constructs this record. The HTTP surface enqueues an event id instead (see ISessionScoringQueue), and the only construction site is the hosted drain worker resolving the handler inside its own DI scope (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringProcessor.cs:190-193). [Rubric §6 - CQRS & Event-Driven] assesses whether writes travel as explicit messages: keeping the run a real ICommandHandler<in TCommand, TResult> command rather than a plain service method means the drain worker goes through the same handler pipeline a controller dispatch would. [Rubric §12 - Performance & Scalability] assesses request economy: the expensive work is named here and executed elsewhere, so the request thread returns in milliseconds.
  • Walkthrough - one positional parameter, EventId (:5), with the one-line summary above it (:3-4).
  • Why it's built this way - a scoring run takes minutes and issues one paid Anthropic call per session (ISessionScoringQueue.cs:19-21). Modelling it as a command lets the queue carry only an id while the handler stays a normal, testable use case.
  • Where it's used - resolved and dispatched by SessionScoringProcessor as ICommandHandler<ScoreEventSessionsCommand, Result<ScoreEventSessionsResultDTO>> (SessionScoringProcessor.cs:190-193); handled by ScoreEventSessionsHandler.

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

SessionScoringEnqueueResult

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ISessionScoringQueue.cs:4 · Level 0 · enum

  • What it is - the three-valued answer to "did my scoring request get in": Queued, AlreadyPending, or QueueFull (:7, :10, :13).
  • Depends on - nothing. It is a bare enum with default integer backing and no attributes.
  • Concept introduced - the tri-state accept, and why it is not a bool. A boolean enqueue result would collapse two refusals that need different words at the API edge: "your run is already in flight, do nothing" versus "the queue is saturated, come back". The controller maps them to distinct problem codes on the same HTTP status, SessionScoring.AlreadyRunning and SessionScoring.QueueFull, both 409 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:112-130, the 409 declared at :109). [Rubric §9 - API & Contract Design] assesses whether a refusal is expressed with enough fidelity for a caller to act on it: an operator seeing "already running" waits, an operator seeing "queue full" retries. [Rubric §29 - Resilience & Business Continuity] assesses back-pressure: refusing outright is the deliberate alternative to blocking a request thread on a bounded channel.
  • Walkthrough - three members in acceptance order, each with a one-line doc comment stating the caller's next move (:6-13). There is no None or Unknown member: every path through TryEnqueue returns one of the three explicitly (SessionScoringQueue.cs:69, :72, :76).
  • Why it's built this way - the dedup decision and the capacity decision are made at different points inside TryEnqueue (a lost TryAdd versus a failed TryWrite), so the return type carries both outcomes rather than forcing the caller to re-inspect the queue.
  • Where it's used - returned by ISessionScoringQueue.TryEnqueue (ISessionScoringQueue.cs:36), produced by SessionScoringQueue, switched on by SessionSelectionController.ScoreSessions (SessionSelectionController.cs:112), and inspected by SessionScoringSweepJob after its recovery enqueue (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringSweepJob.cs:181).

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

SessionScoringResult

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:54 · Level 0 · record (sealed)

  • What it is - what the AI scorer hands back for one session: seven numeric sub-scores, the model's free-text Reasoning, and a Success flag that says whether any of it means anything.
  • Depends on - the SessionIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15) and BCL decimal, string, bool. No first-party types.
  • Concept introduced - the never-throw service result. IAiScoringService contracts that scoring never throws and reports failure in the result instead (IAiScoringService.cs:9), and this record is the vehicle. The adapter's failure path builds it with every score at 0m, Reasoning = "Scoring failed", and Success = false (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AnthropicScoringService.cs:366-379), so one bad session never aborts a loop over hundreds. [Rubric §29 - Resilience & Business Continuity] assesses whether a flaky external dependency degrades one item or the whole run: here it degrades one. [Rubric §13 - Observability & Operability] assesses whether an outcome is legible after the fact: Reasoning is persisted onto SessionAiScore alongside the model id, so an organizer can see why a session scored what it scored and which model said so.
  • Walkthrough - ten required init members (:43-70): SessionId; the seven decimal scores OverallScore, TopicRelevanceScore, DescriptionQualityScore, NoveltyScore, ActionableTakeawaysScore, DepthOrInsightQualityScore, CredibilityExperienceScore, each documented as 1.0 to 10.0; Reasoning; and Success. required on all ten means no partially-populated instance can be constructed, which is what lets the handler read result.SessionId rather than the loop variable when building the entity (ScoreEventSessionsHandler.cs:80).
  • Why it's built this way - the 1.0 to 10.0 range in the doc comments is documentation on this record only. The invariant is enforced one layer in, by SessionAiScore.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:77), whose EnsureScoreInRange rejects anything outside >= 1.0m and <= 10.0m with a SessionAiScore.OutOfRange error (:134-141). Keeping the transport record permissive and the entity strict means a model that returns nonsense produces a counted failure instead of a corrupt row.
  • Where it's used - returned by IAiScoringService.ScoreSessionAsync (IAiScoringService.cs:11-13), produced by AnthropicScoringService and by FakeAiScoringService in the integration test host (MMCA.ADC/Tests/Integration/MMCA.ADC.Conference.IntegrationTests/Infrastructure/FakeAiScoringService.cs), consumed by ScoreEventSessionsHandler (:70-83). It is application-internal: the shape the API returns is SessionAiScoreDTO.
  • Caveats / not-in-source - a Success = false result carries all-zero scores, which SessionAiScore.Create would reject outright. Nothing in the type enforces that pairing; the handler simply never reaches Create on a failed result because it checks Success first (ScoreEventSessionsHandler.cs:72-77).

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

SessionScoringWorkItem

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:21 · Level 0 · readonly record struct

  • What it is - one queued scoring run: which event to score, and which attempt this is. Two values in a readonly record struct marked [StructLayout(LayoutKind.Auto)] (:20-21).
  • Depends on - the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8), plus System.Runtime.InteropServices.StructLayoutAttribute (:2). No first-party dependencies.
  • Concept introduced - retry state that travels with the message. The obvious alternative is a side table of "how many times have I tried event 7", owned by the drain worker. Carrying Attempt on the item instead means the worker keeps no per-event state at all: it reads an item, and if the run throws it re-queues the same item with Attempt + 1 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringProcessor.cs:143). The doc comment names the price honestly: a crash between the failure and the requeue loses the retry, which is the intended floor for an in-process, best-effort queue that an organizer can always trigger again (:9-14). [Rubric §29 - Resilience & Business Continuity] assesses whether the durability level of a mechanism is chosen and stated rather than assumed. [Rubric §12 - Performance & Scalability] applies in a small way: a struct element in a Channel<T> avoids a heap allocation per enqueue, and LayoutKind.Auto lets the runtime pack the fields.
  • Walkthrough - two positional members (:21): EventId and Attempt. Attempt is documented as 1 for the original request, incremented by one on each bounded retry the drain worker schedules (:17-19); the constant supplying that initial 1 lives on the queue as FirstAttempt (SessionScoringQueue.cs:41).
  • Why it's built this way - a readonly record struct gives value equality and immutability with no allocation, which suits a message that is written, read once, and discarded.
  • Where it's used - the element type of the queue's bounded Channel<SessionScoringWorkItem> (SessionScoringQueue.cs:43-49), written by TryEnqueue (:71) and TryRequeue (:97), read by SessionScoringProcessor through queue.Reader.ReadAllAsync (SessionScoringProcessor.cs:107).
  • Caveats / not-in-source - the retry ceiling is not on this type. MaxAttempts = 3 is a private constant on the drain worker (SessionScoringProcessor.cs:74), checked at :143, so nothing in the item itself stops a different consumer from re-queuing forever.

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

SpeakerInfo

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:37 · Level 0 · record (sealed)

  • What it is - the slice of a speaker that the AI model is allowed to see: full name, optional tagline, optional biography (:23-26).
  • Depends on - nothing but BCL strings. Notably it does not carry the SpeakerIdentifierType (a Guid in this module, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19), nor an email, a photo url, or any other Speaker field.
  • Concept introduced - the minimized projection at an external boundary. Everything in this record leaves the system: AnthropicScoringService concatenates the name, tagline, and bio straight into the prompt body it posts to Anthropic (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AnthropicScoringService.cs:257-261). Building a purpose-shaped record instead of passing the entity means the set of fields that can reach a third party is a three-line declaration a reviewer can read at a glance. [Rubric §30 - Compliance/Privacy/Data Governance] assesses data minimization at a processor boundary: the identifier is deliberately absent, so what leaves is speaker-authored public bio text with no key to join it back. [Rubric §11 - Security] assesses whether such a boundary is explicit rather than incidental; here it is a type, not a convention.
  • Walkthrough - three positional members (:23-26), the last two nullable. The handler builds one per non-deleted SessionSpeaker it can resolve, from speaker.FullName, speaker.TagLine, speaker.Bio (ScoreEventSessionsHandler.cs:61-67).
  • Why it's built this way - a scoring prompt needs the speaker's credibility signals and nothing else. Widening the model would silently widen what is sent to a paid third-party API, which is the kind of change a dedicated record forces into review.
  • Where it's used - as the Speakers list on SessionScoringInput (IAiScoringService.cs:51); constructed only in ScoreEventSessionsHandler (:64).
  • Caveats / not-in-source - the doc comments state "max 500 chars" for TagLine and "max 4000 chars" for Bio (:21-22), but nothing in this record, the handler, or the Anthropic adapter truncates or validates either value: the adapter escapes and PII-redacts them before appending, but never truncates (AnthropicScoringService.cs:257-261). Treat those numbers as descriptive of the source fields, not as an enforced bound on the prompt. A different, unrelated SpeakerInfo exists in the Conference UI assembly (SpeakerInfo); the two only share a name.

StatusBucket

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:314 · Level 0 · enum (private, nested)

  • What it is - the private three-member enum nested inside GetSessionSelectionDashboardHandler that collapses a session's free-text status string onto the three columns the dashboard's category tally reports.
  • Depends on - nothing structurally. Values are produced from the SessionStatuses string constants by the handler's own ClassifyStatus (:321-332).
  • Concept introduced - a handler-local aggregation vocabulary. Session.Status is free text imported from Sessionize, and SessionStatuses names six recognized values: Accepted, Waitlisted, AcceptQueue, Nominated, DeclineQueue, Declined (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:17-32). The distribution view does not want six columns, so the handler declares a private three-member enum and folds everything that is neither accepted nor accept-queue into Pending. [Rubric §15 - Best Practices & Code Quality] assesses local reasoning: the bucket type is an implementation detail no caller can see, so the handler can change its bucketing without touching anything else. [Rubric §15 - Best Practices & Code Quality] assesses expressiveness: three named members read better at the tally site than three ad-hoc string comparisons.
  • Walkthrough - three members, Accepted, AcceptQueue, Pending (:314-319). There is deliberately no Declined member, because declined sessions are removed before any bucketing happens: CountCategoryItems filters them with IsDeclined first (:137, :334-335). ClassifyStatus (:321-332) maps a null status or SessionStatuses.Accepted to Accepted, SessionStatuses.AcceptQueue to AcceptQueue, and everything else to Pending, comparing with StringComparison.OrdinalIgnoreCase.
  • Why it's built this way - declined proposals do not compete for a slot, so they are dropped before the enum stage and the three live buckets stay meaningful. Keeping the enum private to the handler avoids a shared type that would couple two otherwise independent use cases.
  • Where it's used - inside its own handler only: the tally in CountCategoryItems (:150-152) and ClassifyStatus (:321-332). Callers receive CategoryDistributionDTO counts, never a bucket value.
  • Caveats / not-in-source - a second, independent StatusBucket with the same three members is declared inside GetCategoryDistributionHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94), with its own matching ClassifyStatus. Nothing in source keeps the two copies in step. A null Session.Status counts as Accepted here (:323-327), which is consistent with the domain's public-visibility allow-list, where an unset status is eligible because organizer-created sessions never carry one (SessionStatuses.cs:47-51), but the code does not restate the reason at the bucketing site. Waitlisted, Nominated, and DeclineQueue all land in Pending with no way to tell them apart in the output.

StatusBucket

MMCA.ADC.Conference.Application · ...DecisionSupport.GetCategoryDistribution and ...DecisionSupport.GetSessionSelectionDashboard · see table · Level 0 · enum (private, nested, two declarations)

  • What it is: two independent private enums, one nested in each of the two handlers that tally sessions by status, that collapse a session's free-text status string onto the three columns those tallies report.
Type File:Line Notes (what differs)
StatusBucket (GetCategoryDistribution) MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetCategoryDistribution/GetCategoryDistributionHandler.cs:94 Members Accepted, AcceptQueue, Pending (:94-99), classified by that handler's own ClassifyStatus (:101-112).
StatusBucket (GetSessionSelectionDashboard) MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:314 The same three members and a matching ClassifyStatus. Nothing in source keeps the two copies in step.
  • Depends on: nothing structurally. Both are produced from the SessionStatuses string constants by their handler's ClassifyStatus.
  • Concept introduced: a handler-local aggregation vocabulary. Session.Status is free text imported from Sessionize, and SessionStatuses names the recognized values (Accepted, Waitlisted, AcceptQueue, Nominated, DeclineQueue, Declined). The distribution views do not want a column per value, so each handler declares a private three-member enum and folds everything that is neither accepted nor accept-queue into Pending. [Rubric §15, Best Practices & Code Quality] assesses local reasoning: the bucket type is an implementation detail no caller can see, so either handler can change its bucketing without touching the other. [Rubric §15, Best Practices & Code Quality] assesses expressiveness: three named members read better at the tally site than three ad-hoc string comparisons.
  • Walkthrough: three members in each declaration: Accepted, AcceptQueue, Pending (GetCategoryDistributionHandler.cs:96-98). There is deliberately no Declined member, because declined sessions are removed before any bucketing happens: IsDeclined filters them out in CountSessionsPerCategoryItem (:45, declared at :114-115). ClassifyStatus (:101-112) maps a null status or SessionStatuses.Accepted to Accepted (:103-107), SessionStatuses.AcceptQueue to AcceptQueue, and everything else to Pending (:109-111), comparing with StringComparison.OrdinalIgnoreCase throughout.
  • Why it's built this way: declined proposals do not compete for a slot, so they are dropped before the enum stage and the three live buckets stay meaningful. Keeping the enum private to each handler avoids a shared type that would couple two otherwise independent use cases.
  • Where it's used: inside its own handler only: GetCategoryDistributionHandler (:58-60, :101-112) and GetSessionSelectionDashboardHandler. Callers receive DTO counts, never a bucket value.
  • Caveats / not-in-source: a null Session.Status counts as Accepted in both copies (GetCategoryDistributionHandler.cs:103-107). That is consistent with the domain's treatment of organizer-created sessions, which never carry an imported status, but the code does not restate the reason at the bucketing site. Waitlisted, Nominated, and DeclineQueue all land in Pending with no way to tell them apart in the output.

ISessionScoringQueue

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ISessionScoringQueue.cs:31 · Level 1 · interface

  • What it is - the producer-side port for AI scoring runs: ask for an event to be scored, or ask whether one is already in flight. Two methods, neither async.
  • Depends on - SessionScoringEnqueueResult (same file, :4) and the EventIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). Nothing external.
  • Concept introduced - the request-shedding work queue, and why fire-and-forget was not enough. The interface's own doc comment (:16-30) is the design record for the whole feature. Scoring an event takes minutes and issues one paid Anthropic call per session, so it cannot run on the request thread; it previously ran as a fire-and-forget task started from the controller, which had three named problems: nothing tracked it, so a deploy or scale-in killed it mid-run with no record; nothing deduplicated it, so two clicks meant two concurrent passes over the same event, doubling the spend and racing each other's writes; and it ignored the host lifetime, so shutdown could not wait for or cancel it (:20-24). The port answers all three by handing the work to a hosted drain. Note the deliberate dedup choice: a second request is refused rather than silently coalesced, so the caller learns the run is already in flight (:26-29). [Rubric §29 - Resilience & Business Continuity] assesses whether long work survives the request that started it. [Rubric §31 - Cost/FinOps] assesses spend control on a metered dependency: dedup here is a money guard as much as a correctness one. [Rubric §3 - Clean Architecture] assesses the direction of dependency: the port is declared in Application, while the channel implementation and the hosted worker that drains it are wired nearer the host, so the controller sees neither.
  • Walkthrough - TryEnqueue(EventIdentifierType) (:36) returns SessionScoringEnqueueResult rather than a bool, so the caller can distinguish "already pending" from "queue full". IsPending(EventIdentifierType) (:40) reports whether a run is queued or currently executing, not merely waiting: the implementation holds the claim until the run finishes (SessionScoringQueue.cs:51-55). Both methods are synchronous, which is what makes the enqueue safe to call from an MVC action with no awaits at all.
  • Why it's built this way - separating the producer port from the concrete SessionScoringQueue keeps the consumer side (Reader, TryRequeue, MarkCompleted) off the interface the API layer can reach. A controller can only ask; only the drain worker, which resolves the concrete class, can consume or complete.
  • Where it's used - injected into SessionSelectionController for POST SessionSelection/score/{eventId} (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:35, :106-131) and into SessionScoringSweepJob, the crash-recovery backstop that re-enqueues events whose pass started but never finished (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringSweepJob.cs:56, rationale at :10-20). Registered as a singleton that forwards to the one concrete instance (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:64-65).

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

SessionScoringInput

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:47 · Level 1 · record (sealed)

  • What it is - everything the AI scorer is given about one session: its id, title, optional description, and the SpeakerInfo projections for its speakers (:33-37).
  • Depends on - SpeakerInfo (same file, :23) and the SessionIdentifierType alias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15). External: IReadOnlyList<T> only.
  • Concept - none new; it is the request half of the never-throw port taught at IAiScoringService, and the minimization rationale is taught at SpeakerInfo. [Rubric §3 - Clean Architecture] assesses whether an external capability is described in the application's own language: this record names a session and its speakers, not a prompt, a token budget, or a JSON body, all of which stay inside the Infrastructure adapter.
  • Walkthrough - four positional members (:33-37). Speakers is documented as possibly empty (:32), and the handler does produce an empty list for a session whose speaker links resolve to nothing (ScoreEventSessionsHandler.cs:61-67). SessionId is carried through the call and echoed back on SessionScoringResult, which is what lets the handler pair a result with its session without holding a map.
  • Why it's built this way - passing a purpose-built input record rather than the Session entity keeps the domain aggregate out of the adapter and keeps the prompt's ingredients auditable in four lines.
  • Where it's used - the sole payload parameter of IAiScoringService.ScoreSessionAsync (:11-13); built once per session by ScoreEventSessionsHandler (:69), consumed by AnthropicScoringService.

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

IAiScoringService

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/IAiScoringService.cs:6 · Level 2 · interface

  • What it is - the application-layer port for scoring one conference session with an AI model, plus two provenance properties: which model did the scoring, and which version of the prompt contract it was asked with.
  • Depends on - SessionScoringInput and SessionScoringResult, both declared in this same file (:47, :54), which in turn use SpeakerInfo (:37). External: BCL only. There is no Anthropic client type anywhere in the Application assembly.
  • Concept introduced - port and adapter for an unreliable, paid, external capability. [Rubric §3 - Clean Architecture] assesses which layer owns the abstraction: the application declares the port, while the HTTP client, the prompt text, the JSON contract records, and the API key all live in Infrastructure's AnthropicScoringService, bound by AddHttpClient<IAiScoringService, AnthropicScoringService> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:33). [Rubric §1 - SOLID] assesses the dependency-inversion direction: ScoreEventSessionsHandler depends on this interface, so swapping vendors touches one registration. [Rubric §29 - Resilience & Business Continuity] assesses failure containment: the "Never throws (failure is indicated in the result)" clause (:9) is the Result philosophy applied to a network call, and it is what lets the handler's per-session loop keep going. [Rubric §13 - Observability & Operability] assesses whether an AI output can be explained after the fact: the port makes both the model id (:16) and the prompt version (:30) part of the contract, so every stored score carries the two inputs that decide what it means (the governance rule is ADR-111, Website/docs-src/adr/111-ai-session-scoring-governance.md). [Rubric §14 - Testability] assesses whether the use case can run without the dependency: FakeAiScoringService implements this interface, so the scoring path is exercised with no HTTP and no API key.
  • Walkthrough
    • ScoreSessionAsync(SessionScoringInput, CancellationToken = default) (:11-13) returns Task<SessionScoringResult>. There is no Result<T> here and no exception path: the success or failure signal is the Success flag on the returned record.
    • ModelId { get; } (:16) exposes which model produced a score. The handler stamps it onto the persisted entity as the modelUsed argument (ScoreEventSessionsHandler.cs:83), so a score row records both the number and its provenance.
    • PromptVersion { get; } (:30) exposes the version of the prompt contract the implementation sends, as a dated yyyy-MM-dd.N string, and the handler stamps it on the same call (ScoreEventSessionsHandler.cs:83). The doc comment (:18-29) is where the operating rule lives: bump it on any change to the system prompt, the user-prompt assembly (speaker formatting included), the redaction rules applied to submitted text, or the structured-output schema. It also records why the rule holds, that the golden evaluation suite pins the rendered prompt by hash per version, so an unbumped prompt change fails there rather than silently re-basing every score.
    • Scope: one session per call. Nothing on this interface batches, so the fan-out policy (sequential, one at a time) is the handler's decision rather than the port's.
  • Why it's built this way - defining the port in Application lets the scoring use case be exercised with a fake scorer, and lets the AI vendor change without touching the handler. Exposing ModelId and PromptVersion on the port rather than hard-coding strings in the handler means the recorded provenance cannot drift from the client that actually made the call: the implementation that assembles the prompt is the one thing that can honestly name its version.
  • Where it's used - constructor-injected into ScoreEventSessionsHandler (:20); implemented by AnthropicScoringService in production and FakeAiScoringService in the integration test host.

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

SessionScoringQueue

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringQueue.cs:34 · Level 2 · class (sealed)

  • What it is - the bounded in-process implementation of ISessionScoringQueue: a 16-slot channel of SessionScoringWorkItem values plus a concurrent set of the events currently claimed, registered as a singleton and drained by one hosted worker.
  • Depends on - ISessionScoringQueue, SessionScoringWorkItem (same file, :21), and SessionScoringEnqueueResult. External: System.Threading.Channels.Channel<T> and System.Collections.Concurrent.ConcurrentDictionary<TKey, TValue> (:1-3).
  • Concept introduced - claim-then-write, and refuse rather than drop. Two mechanisms interlock here and both repay a close read.
    • Refuse rather than drop. The channel is created with BoundedChannelFullMode.Wait (:46) but is only ever written through the non-blocking TryWrite (:71, :97). That combination means a full queue makes TryWrite return false immediately instead of blocking the caller or evicting an older item. The doc comment states why the alternative is wrong for this workload: unlike an ephemeral live broadcast, a scoring run is expensive and the caller needs to know it was not accepted (:26-29). [Rubric §29 - Resilience & Business Continuity] assesses back-pressure policy; [Rubric §31 - Cost/FinOps] assesses spend control, since every accepted run is real money.
    • Claim first, then write. TryEnqueue adds to _pending before touching the channel (:68), so of two concurrent duplicate requests exactly one wins the TryAdd and the other is refused (:66-69). If the subsequent TryWrite fails, the claim is released again (:75) so a request that never queued cannot lock the event out. Taking the claim after a successful write would leave a window in which a second caller sees no claim and enqueues a duplicate.
    • SingleReader = true (:47) encodes that exactly one drain worker consumes the channel, so runs execute one at a time and cannot contend for the same event's rows (:30-31). SingleWriter = false (:48) admits many concurrent producers.
  • Walkthrough
    • Capacity = 16 (:38), with the comment stating the intent: organizers score a handful of events, so the bound exists to refuse a runaway caller, not to absorb load (:36-37). FirstAttempt = 1 (:41) is the attempt number stamped on an item queued from an original request.
    • _channel (:43-49), the bounded channel described above; _pending (:55), a ConcurrentDictionary<EventIdentifierType, byte> used as a set. Its doc comment names the important subtlety: the drain removes an entry only after the run finishes, so the dedup window covers execution too, not just the wait in the queue (:51-54).
    • Reader (:58) exposes the ChannelReader<SessionScoringWorkItem> for the hosted drain. It is on the class, not on the interface, so only a consumer holding the concrete type can read.
    • IsPending(eventId) (:61) is a dictionary lookup.
    • TryEnqueue(eventId) (:64-77) returns AlreadyPending on a lost claim (:69), Queued on a successful write (:72), or QueueFull after releasing the claim (:75-76).
    • TryRequeue(eventId, attempt) (:93-103) is the retry path and deliberately does not refuse an already-claimed event: its caller is the drain worker itself, which has just finished the run that held the claim, so re-adding is the point rather than a duplicate (:83-89). A full channel is handled exactly as on the enqueue path, by giving the claim back (:100-102).
    • MarkCompleted(eventId) (:110) clears the claim once a run has finished, successfully or not.
  • Why it's built this way - the dedup guarantee is only as good as the ordering of the claim and the write, and the two release paths exist so that a refusal never leaves a permanent phantom claim behind. Note what the class does not try to be: durable. It is in-process memory, so a replica restart loses queued items, which is why SessionScoringSweepJob exists as a slower crash-recovery backstop (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringSweepJob.cs:10-20).
  • Where it's used - registered twice on purpose: TryAddSingleton<SessionScoringQueue>() and then TryAddSingleton<ISessionScoringQueue>(sp => sp.GetRequiredService<SessionScoringQueue>()), so both registrations resolve to the one instance (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:64-65; the comment at :57-60 notes that producers would otherwise write to a queue nobody drains). Producers hold the interface; SessionScoringProcessor holds the concrete class and uses Reader, MarkCompleted, and TryRequeue (SessionScoringProcessor.cs:50, :107, :135, :143). Exercised directly by SessionScoringQueueTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DecisionSupport/SessionScoringQueueTests.cs:11).
  • Caveats / not-in-source - dedup is per process. Conference runs with more than one replica, so two triggers landing on different replicas both pass this class's _pending check; the cross-replica guard is an IDistributedLock taken by the drain worker before it invokes the handler, and a host with no Redis configured falls back to per-replica exclusion again (SessionScoringProcessor.cs:162-188). Nothing here bounds how long a claim may live: MarkCompleted is the only release, so a consumer that neither completes nor crashes would hold an event's claim indefinitely.

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

GetSessionSelectionDashboardHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSessionSelectionDashboard · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboard/GetSessionSelectionDashboardHandler.cs:16 · Level 9 · class (sealed)

  • What it is - the composite handler behind the session-selection screen. It validates the event, loads sessions, categories, speakers, and AI scores once, and computes five blocks from that one snapshot: summary counts, category distribution, speaker overlap, speaker locality, and per-session AI scores.
  • Depends on - IQueryHandler<in TQuery, TResult> (implemented) and IUnitOfWork (the only constructor dependency, :16-17); the aggregates Event, Session, Speaker, Category, and SessionAiScore; SpeakerLocalityHelper and its LocalityLookupEntry; SessionStatuses; the nested StatusBucket; Result and Error; and the output contracts SessionSelectionDashboardDTO, CategoryDistributionDTO, SpeakerSessionOverlapDTO, MultiSessionSpeaker, SpeakerSessionSummary, SpeakerLocalitySummary, and SessionAiScoreDTO (:5).
  • Concept introduced - one snapshot, many projections, and the deliberate query-filter escape. Two mechanisms are worth learning here. The first is the load-once discipline: the comment at :33 records that the loads stay sequential for EF single-context safety (a DbContext is not thread-safe, so "parallel-friendly" here means ordered and independent, not concurrent), and every later block is a pure function over the already-materialized collections. The second is the one place the handler steps outside the framework's defaults: the speaker load passes ignoreQueryFilters: true (:61), turning off the global soft-delete filter for that read only. The comment above it explains the rule (:52-56): speaker deletion deliberately does not cascade to SessionSpeaker links (BR-70/BR-71), so a live link can point at a soft-deleted speaker, and honoring the filter would render that row as "Unknown" instead of the truth. [Rubric §8 - Data Architecture] assesses whether soft-delete semantics are applied deliberately rather than by reflex: this is an explicit, commented, single-read opt-out, and the comment notes that every downstream consumer re-filters the child collections in memory, so the escape cannot resurrect deleted category items. [Rubric §12 - Performance & Scalability] assesses request economy: five loads serve five projections. [Rubric §15 - Best Practices & Code Quality] assesses duplication, and that is the honest weak point (see the caveats).
  • Walkthrough - HandleAsync (:19-122) resolves four repositories (:23-26), then fetches the Event and returns Error.NotFound decorated with source and target when it is missing (:29-31): this is the only decision-support handler with a failure path, pinned by HandleAsync_WhenEventNotFound_ReturnsNotFound (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSessionSelectionDashboardHandlerTests.cs:198). It loads the event's non-service sessions with SessionSpeakers and SessionCategoryItems included, untracked (:34-38), all categories with their items (:40-43), the distinct speaker ids referenced by live session-speaker links (:46-50), and those speakers with SpeakerCategoryItems included and query filters off (:57-62), indexed into a dictionary (:63). Two lookups follow: category-item id to name (:66-73) and the locality lookup built by SpeakerLocalityHelper from the locality categories it finds (:75-76). The summary counts are four Count passes plus one subtraction: accepted counts null-or-Accepted statuses, accept-queue and declined count their constants, and pending is the remainder (:79-86). ComputeCategoryDistribution (:89, :124-131) reuses the same tally-then-group shape as GetCategoryDistributionHandler, via CountCategoryItems (:133-156) and BuildCategoryGroups (:158-184). ComputeSpeakerOverlap (:92, :186-253) groups sessions by live speaker link, skips ids missing from the lookup (:213-214), stamps each speaker's locality tier (:224), orders each speaker's sessions by title (:227), and sorts speakers by session count descending, then accepted-session presence, then name (:240-250). ComputeSpeakerLocality (:95, :255-312) re-groups the same sessions by speaker, resolves each speaker to a tier defaulting to "Unknown" (:283-287), accumulates speaker, session, accepted, and accept-queue counts per tier in a case-insensitive dictionary (:279, :289-299), and emits SpeakerLocalitySummary rows ordered by speaker count descending (:302-311). Finally the AI-score block loads every SessionAiScore whose SessionId is in the loaded set (:98-104), and BuildAiScoreDtos (:337-360) orders them by OverallScore descending and projects each one through BuildSingleAiScoreDto (:362-393). That projection copies the score's provenance straight through, ModelUsed and PromptVersion (:385-386), so the screen can attribute a number to the model and the prompt contract behind it. It is also where the "Level" category is special-cased: the handler finds the first non-deleted category whose title contains "Level" (:347-348), collects its item ids (:349-351), and ResolveCategoryInfo (:411-433) then splits a session's tags into ordinary categories and the single level value. ResolveSpeakerLocalities (:395-409) produces the distinct tier names for a scored session's speakers, again defaulting to "Unknown". ScoredOn prefers LastModifiedOn and falls back to CreatedOn (:387), the audit fields the framework stamps on save.
  • Why it's built this way - the screen needs internally consistent numbers, and computing every block from one materialized snapshot is what guarantees the speaker panel and the category panel describe the same set of sessions. The AI scores are read here rather than computed here because scoring is background work: ScoreEventSessionsHandler does the writing off the request path and the dashboard surfaces whatever rows exist, which is why the queue endpoint tells the caller to refresh after a few minutes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:96-100).
  • Where it's used - injected into SessionSelectionController as IQueryHandler<GetSessionSelectionDashboardQuery, Result<SessionSelectionDashboardDTO>> (SessionSelectionController.cs:31) and invoked from GET SessionSelection/dashboard/{eventId} (:39-51), the one decision-support endpoint the Blazor UI calls, through SessionSelectionService (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Services/Sessions/Selection/SessionSelectionService.cs:14-32) and onto the SessionSelectionDashboard page.
  • Caveats / not-in-source - the category-distribution and speaker-overlap logic is duplicated rather than shared: CountCategoryItems and BuildCategoryGroups here (:133-156, :158-184) mirror GetCategoryDistributionHandler's equivalents, and ComputeSpeakerOverlap (:186-253) mirrors GetSpeakerSessionOverlapHandler's BuildMultiSessionSpeakers. Nothing in source keeps the copies aligned, and they already differ in one visible way: this handler loads speakers with ignoreQueryFilters: true (:61) while the standalone overlap handler does not (GetSpeakerSessionOverlapHandler.cs:41-45), so a soft-deleted speaker appears on the dashboard and is absent from the narrow endpoint. The "Level" category is matched by a substring of the category title (:347-348), so renaming that category upstream silently empties SessionLevel. A session carrying more than one level tag resolves to whichever ResolveCategoryInfo encounters first (:427-430), decided by the enumeration order of the session's links. The handler resolves the read-write GetRepository for all five entity types (:23-26, :98) although it only reads: IUnitOfWork exposes a narrower GetReadRepository alongside it (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:19 versus :29).

GetSpeakerSessionOverlapHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.GetSpeakerSessionOverlap · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlap/GetSpeakerSessionOverlapHandler.cs:18 · Level 9 · class (sealed)

  • What it is - the query handler that returns every speaker who submitted at least one session for an event, each with their sessions and locality tier, sorted so speakers holding several proposals appear first.
  • Depends on - IQueryHandler<in TQuery, TResult> (implemented) and IUnitOfWork (the only constructor dependency, :18-19); the aggregates Session, Speaker, and Category plus the SessionSpeaker and SpeakerCategoryItem links; SpeakerLocalityHelper and LocalityLookupEntry; SessionStatuses; and the output contracts SpeakerSessionOverlapDTO, MultiSessionSpeaker, and SpeakerSessionSummary (:4).
  • Concept introduced - inverting an aggregate's direction in memory. The database is queried session-first (sessions for an event, with their speaker links included, :29-33), but the answer is speaker-first. GroupSessionsBySpeaker (:61-82) performs that inversion: it flattens sessions into (SpeakerId, Session) pairs while skipping soft-deleted links (:64-67) and folds them into a dictionary of speaker to session list. Only then does the handler know which speakers to fetch, which is why the Speaker load is a GetByIdsAsync over the collected keys (:41-45, the interface at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:48) rather than a second broad query. [Rubric §12 - Performance & Scalability] assesses query shape: three loads, no per-speaker round trip, and an early return that skips the speaker and category loads entirely when the event has no sessions (:38-39), a path pinned by HandleAsync_WithNoSessions_ReturnsEmptyAndSkipsSpeakerLookup (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetSpeakerSessionOverlapHandlerTests.cs:131). [Rubric §4 - DDD] assesses whether a concept is read from the aggregates the domain actually has: a speaker's origin is not a column but a category assignment, resolved by SpeakerLocalityHelper (:52-53, :119). [Rubric §6 - CQRS & Event-Driven] applies as for the sibling reads: a pure query with no failure path.
  • Walkthrough - HandleAsync (:21-59) resolves session, speaker, and category repositories (:25-27), loads the event's non-service sessions with SessionSpeakers and SessionCategoryItems included and asTracking: false (:29-33), inverts them into the speaker-to-sessions dictionary (:35), and returns an empty SpeakerSessionOverlapDTO when no speaker was referenced (:38-39). Otherwise it loads exactly those speakers with SpeakerCategoryItems included (:41-45) and all categories with their items (:47-50), then builds two lookups: the locality lookup, via SpeakerLocalityHelper.BuildLocalityLookup(SpeakerLocalityHelper.FindLocalityCategories(categories)) (:52-53), and category-item id to name (:54, :84-97). BuildMultiSessionSpeakers (:99-140) walks the loaded speakers, skips any with no sessions in the dictionary (:108-109), computes HasAcceptedSession by treating a null status or SessionStatuses.Accepted as accepted with OrdinalIgnoreCase (:111-113), stamps the locality tier (:119), and orders each speaker's sessions by title case-insensitively (:122). Each session becomes a SpeakerSessionSummary through BuildSessionSummary (:142-153), which carries id, title, raw status, and the names of the session's non-deleted category items. The final sort (:127-137) is three-level: session count descending, then accepted-session presence, then speaker name with OrdinalIgnoreCase, which is what makes the list deterministic rather than dictionary-enumeration order.
  • Why it's built this way - the class summary states the scope decision explicitly (:11-17): the endpoint returns every speaker, not only multi-session ones, because the UI renders a session-count column and lets the organizer see the whole roster while the sort surfaces the overlap cases first. Filtering server-side to speakers with two or more sessions would have made that same screen impossible without a second call.
  • Where it's used - injected into SessionSelectionController as IQueryHandler<GetSpeakerSessionOverlapQuery, Result<SpeakerSessionOverlapDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:33) and invoked from GET SessionSelection/speaker-overlap/{eventId} (:67-79). The Blazor UI does not call this endpoint: it reads the equivalent block off the dashboard response instead.
  • Caveats / not-in-source - the type and method names are residue from the narrower original scope: the DTO element is still MultiSessionSpeaker and the builder is still BuildMultiSessionSpeakers (:99) even though single-session speakers are included (HandleAsync_IncludesSingleSessionSpeakers, GetSpeakerSessionOverlapHandlerTests.cs:174). Unlike GetSessionSelectionDashboardHandler, this handler's GetByIdsAsync call does not pass ignoreQueryFilters (:41-45, versus GetSessionSelectionDashboardHandler.cs:57-62), so the global soft-delete filter applies and a soft-deleted speaker is skipped along with every session only they submitted (HandleAsync_SkipsSpeakersMissingFromRepository, GetSpeakerSessionOverlapHandlerTests.cs:217). Whether that difference is intended is not stated in either file. LocalityCategory stays null when a speaker has no locality assignment (:119); this handler does not substitute "Unknown" the way the dashboard's locality block does.

ScoreEventSessionsHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.DecisionSupport.ScoreEventSessions · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsHandler.cs:18 · Level 9 · class (sealed, partial)

  • What it is - the use case that walks an event's sessions one at a time, asks the AI scorer about each, and persists each score the moment it arrives, returning how many were scored and how many failed.
  • Depends on - IUnitOfWork, IAiScoringService, and ILogger<ScoreEventSessionsHandler> by primary constructor (:18-21); Session, Speaker, and SessionAiScore from the domain; ScoreEventSessionsResultDTO as the payload; Result and Error. It implements ICommandHandler<in TCommand, TResult> (:21).
  • Concept introduced - incremental commit, and per-item replacement instead of an up-front wipe. This handler is the clearest example in the group of a long-running use case designed around the question "what does a run that dies halfway leave behind?".
    • Commit per session, not per run. SaveChangesAsync is called inside the loop (:108), so the UI can show real-time progress and a run killed at session 40 of 200 leaves 40 durable scores. That is the opposite of the usual one-transaction-per-command shape, and the summary says so explicitly (:12-17).
    • Replace in the same step that writes. The comment at :94-103 records the failure this design fixes: an up-front bulk delete of the event's scores made the dashboard reset to zero and count up, but it paid for that with every existing score on the event, so the first Anthropic call to fail on an expired key or a rate limit left the sessions it never reached with no score at all. Per-session granularity means a run that dies partway through has replaced only what it re-scored, and a session whose call failed keeps the score it already had. The delete-then-add pair is safe because the unique, soft-delete-filtered index on SessionId in SessionAiScoreConfiguration permits at most one live row per session either way (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionAiScoreConfiguration.cs:66-68).
    • [Rubric §8 - Data Architecture] assesses write granularity and the invariants the schema itself enforces; [Rubric §29 - Resilience & Business Continuity] assesses partial-failure behavior; [Rubric §31 - Cost/FinOps] assesses paid-call economy, since every failure that forces a full re-run costs money again; [Rubric §13 - Observability & Operability] assesses run legibility, which here is six source-generated [LoggerMessage] methods carrying progress counters (:138-154).
  • Walkthrough
    • Repositories for Session and SessionAiScore off the unit of work (:27-28).
    • Load the event's sessions with SessionSpeakers included, excluding service sessions, asTracking: false (:30-34). An event with no sessions short-circuits to a success carrying zeroes (:36-37), not a failure.
    • Batch-load speakers: flatten SessionSpeakers, drop soft-deleted links, distinct the speaker ids, one GetByIdsAsync (:40-50), then a dictionary by id (:51). This is the N+1 avoidance step: one speaker query for the whole event rather than one per session.
    • The loop (:59-118). Per session: project the non-deleted speaker links into SpeakerInfo values, skipping ids the lookup does not resolve (:61-67); build a SessionScoringInput (:69); call ScoreSessionAsync (:70).
    • Two failure gates before any write. !result.Success counts a failure and continues (:72-77). SessionAiScore.Create returning a failure (an out-of-range score, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionAiScore.cs:171-178) also counts a failure and continues (:85-90), so a model that answers with a 12 is rejected at the domain boundary rather than persisted. The Create call passes both provenance values off the port, aiScoringService.ModelId and aiScoringService.PromptVersion (:79-83), so the persisted row names the model and the prompt contract that produced the number.
    • The write step (:92-117): ExecuteDeleteAsync for this session's existing score rows, accumulating the count into replaced (:105); AddAsync for the new entity (:107); SaveChangesAsync (:108). It is wrapped in catch (Exception ex) when (ex is not OperationCanceledException) (:113), so a save failure counts as one failed session and the loop continues, while a cancellation still propagates and unwinds the run.
    • Outcome (:120-135): log the replaced count if any, log the totals, then one policy decision. If nothing scored and something failed, return a Result failure with code AiScoring.AllFailed naming the likely cause (:127-133). Any partial success returns Result.Success with the counts, which is what keeps the drain worker from retrying a business outcome.
  • Why it's built this way - the run is long, paid, and externally fallible, so the design optimizes for "every session that was successfully scored stays scored" over transactional all-or-nothing. The AllFailed failure exists so that a total washout (an expired key, a wrong endpoint) is loud rather than a silent success reporting zero.
  • Where it's used - resolved by SessionScoringProcessor inside a per-run DI scope and invoked with a ScoreEventSessionsCommand (SessionScoringProcessor.cs:190-193). No controller calls it directly; the HTTP surface only enqueues. Behavior is pinned by ScoreEventSessionsHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/ScoreEventSessionsHandlerTests.cs:13).
  • Caveats / not-in-source - ExecuteDeleteAsync is a set-based database delete that bypasses change tracking, domain events, audit stamps, and soft-delete entirely (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:419-429), so a replaced AI score is physically gone rather than flagged IsDeleted. Scoring is also strictly sequential, one Anthropic call at a time with no concurrency knob, so wall-clock time grows linearly with session count. The replaced counter is logged (:138-139) but is not part of ScoreEventSessionsResultDTO, which carries only SessionsScored and SessionsFailed.

[Rubric §16, AI-Native Application Architecture] applies: this type is part of the AI session-scoring feature (a model call behind a port, versioned prompt and model, an evaluation gate, metered spend; ADR-111).

ExportEventCalendarQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarQuery.cs:5 · Level 0 · record (sealed)

  • What it is: the read request behind "add the whole conference to my calendar": one event id, answered with an RFC 5545 .ics document covering every exportable session on that event's schedule.
  • Depends on: the EventIdentifierType alias, an int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). No other first-party types, nothing external.
  • Concept introduced: the query whose result is a document, not a DTO. Every other read in this group resolves to a shaped DTO; this one resolves to Result<string> where the string is a complete calendar file (ExportEventCalendarHandler.cs:17). The query/handler split itself is taught by IQueryHandler<in TQuery, TResult> and is cross-referenced rather than re-taught. [Rubric §9, API & Contract Design] assesses whether a contract matches the representation its consumer needs: a calendar client wants text/calendar bytes, so the use case produces the serialized document and the controller only wraps it in a File(...) response (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:161-164). [Rubric §6, CQRS & Event-Driven] applies as for the sibling reads: a named message with no side effects, bound to exactly one handler by the generic argument.
  • Walkthrough: one positional parameter, EventId (:5), documented by the summary and <param> above it (:3-4). No body, no defaults, no marker interface.
  • Why it's built this way: the event-wide and single-session exports are genuinely different reads (one loads the whole schedule plus the room map, the other loads one session and then checks its parent), so they get separate messages instead of one query with a nullable session id. See ExportSessionCalendarQuery for the narrow twin.
  • Where it's used: constructed by EventsController on GET Events/{id}/ics, an [AllowAnonymous] action under the EventsCache output-cache policy (EventsController.cs:154-165), resolved through the injected IQueryHandler<ExportEventCalendarQuery, Result<string>> (:54). The browser-side caller is the add-to-calendar button on PublicEventDetail (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicEventDetail.razor:27-28).
  • Caveats / not-in-source: the doc comment tags the feature "ADR-042 Wave 5" (:3). ADR-042 is the MAUI device-capability abstraction (Website/docs-src/adr/042-device-capability-abstraction.md:1) and says nothing about iCalendar, so read that tag as a delivery-wave label rather than as a pointer to a specification of this export.

ExportSessionCalendarQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarQuery.cs:5 · Level 0 · record (sealed)

  • What it is: the read request for a single-session .ics document, the one behind the "add to calendar" button on a session page.
  • Depends on: the SessionIdentifierType alias, an int in this module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15). Nothing else.
  • Concept: none new; it is the same document-producing read message as ExportEventCalendarQuery, which teaches the shape. [Rubric §5, Vertical Slice] assesses feature cohesion: both queries, both handlers, and the mapper they share sit in one ExportCalendar folder, so the whole capability is one directory.
  • Walkthrough: one positional parameter, SessionId (:5), with the summary and <param> above it (:3-4).
  • Why it's built this way: the single-session export is what a public attendee actually uses while browsing the agenda, and it enforces a stricter rule than the event export does (the session itself must be exportable, not merely present in a published event). Keeping it a separate message keeps that rule in one handler rather than as a branch inside a combined one.
  • Where it's used: constructed by SessionsController on GET Sessions/{id}/ics, [AllowAnonymous] under the SessionsCache policy (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:261-272), through the injected IQueryHandler<ExportSessionCalendarQuery, Result<string>> (:52). The UI caller is the add-to-calendar button on PublicSessionDetail (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/PublicSessionDetail.razor:38-39).

GetPublicSessionCategoryItemFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionCategoryItemFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterQuery.cs:8 · Level 0 · record (sealed)

  • What it is: a parameterless marker query asking for the filter that limits SessionCategoryItem junction rows to those whose parent session is publicly visible. The whole type is one line: public sealed record GetPublicSessionCategoryItemFilterQuery; (GetPublicSessionCategoryItemFilterQuery.cs:8).
  • Depends on: nothing first-party, nothing external. It is an empty record with no positional parameters.
  • Concept: none new. The marker-query shape is taught under GetPublicSessionFilterQuery, the junction dimension under GetPublicSessionSpeakerFilterQuery, and the visibility rule itself lives once in PublicConferenceVisibility. The doc comment (:3-7) names the leak the query closes: a junction row is readable only when its parent session is publicly visible (the BR-49 status allow-list, inside a BR-108 published event), because otherwise the join endpoints would list the categories of a hidden session and so reveal that the session exists. [Rubric §11, Security] assesses whether an anonymous surface can be used to infer the existence of content the caller may not read; a join table is exactly the surface that gets forgotten once the parent entity is locked down.
  • Walkthrough: no members. Note what is deliberately absent: unlike GetSessionsBySpeakerFilterQuery, which carries the speaker it filters by, this query takes no argument at all, because the junction reads carry no scope to narrow to. Every line of behavior lives in GetPublicSessionCategoryItemFilterHandler.
  • Why it's built this way: the rule belongs to the parent session, not to the join row, so the query carries no arguments and the handler derives its answer from the shared resolver instead of restating BR-49 a second time.
  • Where it's used: handled by GetPublicSessionCategoryItemFilterHandler; injected into SessionCategoryItemsController as an IQueryHandler<...> constructor parameter (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionCategoryItemsController.cs:52) and constructed inside that controller's GetReadSpecificationAsync override (SessionCategoryItemsController.cs:73-83), which returns null for privileged readers (:76-77) and the specification for everyone else (:79-82). That override is the framework's read hook, so all four anonymous reads inherit the scope from one place: the unpaged list (:85-88), the paged list (:95-98), the lookup (:114-117), and the by-id read (:122-125), where a hidden parent session turns the row into a 404 rather than a redacted record.

GetPublicSessionFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterQuery.cs:11 · Level 0 · record (sealed)

  • What it is: the request for the BR-132 / BR-49 public-session filter, the single definition of "which sessions may a non-privileged caller see". The type is one line with no members: public sealed record GetPublicSessionFilterQuery; (GetPublicSessionFilterQuery.cs:11).
  • Depends on: nothing first-party, nothing external. Its whole payload is its identity as a message type.
  • Concept introduced: the marker query whose answer is a filter, not rows. Most read slices in this chapter return DTOs, and the calendar pair above returns a document. This family returns a Specification<TEntity, TIdentifierType>, a reusable predicate object the caller composes with its own paging, sorting, and filters before a single row is fetched. That indirection is what lets one visibility rule serve four different endpoint shapes (list, paged list, lookup, by-id) without any of them restating it. The query needs no members because the rule takes no parameters: it is the same rule for every caller who is not privileged. [Rubric §11, Security] assesses whether an authorization rule is enforced once, server side, on every path that can reach the data. The doc comment (:3-10) spells out the rule and why it is an allow-list rather than a deny-list: Accepted-or-unset sessions whose parent event is published, so a session in any other state (waitlisted, nominated, queued, declined, or an unrecognized Sessionize value) is invisible by default. A deny-list would silently expose the next status Sessionize invents. [Rubric §2, Design Patterns] assesses whether a recognized pattern is used where it earns its keep. Specification-as-return-value keeps the predicate composable: SessionsController ANDs it with the speaker filter rather than choosing between them (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:127-129).
  • Walkthrough: no members, no methods, no validation. The doc comment (:6-9) also records the physical constraint that shapes the handler: the design treats Session and Event as potentially living in different data sources, so the published-event check cannot be a navigation join and the handler delegates to the framework's cross-source specification helper.
  • Why it's built this way: an empty record still buys a distinct type, and a distinct type is what the CQRS pipeline dispatches on. new GetPublicSessionFilterQuery() selects GetPublicSessionFilterHandler through the module's Scrutor scan, services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133, the strategy documented at :43-44), which registers every implementation of IQueryHandler<in TQuery, TResult> in the module assembly with a scoped lifetime (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:246-250). The visibility rule is therefore reached the same way every other read is, with the same decorators around it.
  • Where it's used: handled by GetPublicSessionFilterHandler; injected into SessionsController (SessionsController.cs:51) and constructed in that controller's GetReadSpecificationAsync override (SessionsController.cs:77-87), which short-circuits to null for privileged readers (:79-80). That hook feeds the unpaged list (:148), the paged list through BuildPagedSessionSpecificationAsync (:110, applied at :187), the lookup (:213-216), and the by-id read (:225-231), each of which is [AllowAnonymous] (:136, :162, :211, :223) under the class-level [HasPermission(ConferencePermissions.SessionsManage)] (:43).
  • Caveats / not-in-source: the doc comment states that Session lives in Cosmos DB and Event in SQL Server (:7-8), and the controller repeats it (SessionsController.cs:66-68). In ADC as configured today both are SQL Server entities: SessionConfiguration derives from EntityTypeConfigurationSQLServer<Session, SessionIdentifierType> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionConfiguration.cs:12-13) and EventConfiguration from the same SQL Server base (.../EntityConfiguration/EventConfiguration.cs:11-12). The cross-source treatment is therefore prophylactic against the polyglot option (ADR-018) rather than a description of the deployed engine split.

GetPublicSessionSpeakerFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionSpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterQuery.cs:8 · Level 0 · record (sealed)

  • What it is: the marker query for the filter that limits SessionSpeaker junction rows to those whose parent session is publicly visible. One line, no members (GetPublicSessionSpeakerFilterQuery.cs:8).
  • Depends on: nothing first-party, nothing external.
  • Concept introduced: visibility propagates down a junction. The marker shape itself comes from GetPublicSessionFilterQuery; what this query adds is the observation that hiding an entity is not finished until every table pointing at it is hidden too. The doc comment (:3-7) states the leak in one sentence: without this filter the join endpoints would list the speakers of a hidden session and thereby leak its existence. [Rubric §11, Security]: the junction is an independent read surface with its own controller and its own anonymous actions, so it needs its own enforcement rather than inheriting one.
  • Walkthrough: no members. The filter is derived, not parameterized, so GetPublicSessionSpeakerFilterHandler can compute the answer from the same visible-session id list the category-item filter uses.
  • Why it's built this way: giving the join its own query type (rather than reusing GetPublicSessionFilterQuery and translating the result) keeps each handler's return type bound to the entity being filtered: this one yields Specification<SessionSpeaker, SessionSpeakerIdentifierType>, which the join controller returns straight from the framework read hook with no adaptation.
  • Where it's used: handled by GetPublicSessionSpeakerFilterHandler; injected into SessionSpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSpeakersController.cs:52) and constructed in that controller's GetReadSpecificationAsync override (SessionSpeakersController.cs:73-83, privileged short circuit at :76-77). The hook feeds the unpaged list (:85-88), the paged list (:95-98), the lookup (:114-117), and the by-id read (:122-125), all [AllowAnonymous] (:86, :96, :115, :123) beneath the class-level [HasPermission(ConferencePermissions.SessionsManage)] (:47).

GetSessionsBySpeakerFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.GetSessionsBySpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetSessionsBySpeakerFilter/GetSessionsBySpeakerFilterQuery.cs:11 · Level 0 · record (sealed)

  • What it is: the one member of this filter family that carries an argument: public sealed record GetSessionsBySpeakerFilterQuery(SpeakerIdentifierType SpeakerId); (GetSessionsBySpeakerFilterQuery.cs:11). Its answer is the specification selecting the sessions a given speaker presents.
  • Depends on: the SpeakerIdentifierType alias (System.Guid in Conference, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19). Nothing else.
  • Concept introduced: the virtual filter key. A client filtering the paged session list by speaker sends SpeakerId as an ordinary filter key, but Session has no SpeakerId column: the link lives in the SessionSpeaker join. The doc comment (:4-9) records the resolution: the link is resolved as an ID-list projection so the resulting criteria stays engine-portable and the Session aggregate keeps a by-id boundary to Speaker, following the GetSpeakersByEventFilterQuery precedent (BR-132). [Rubric §4, DDD] assesses whether aggregates reference each other by identifier instead of by object graph; this query exists precisely so a cross-aggregate question can be answered without giving Session a navigation to Speaker. [Rubric §9, API & Contract Design]: the key is intercepted in the controller and never forwarded to the generic filter pipeline, which rejects unknown properties, and an unparseable value ignores the key rather than failing the request (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:95-99, :112-117).
  • Walkthrough: a single positional member, SpeakerId (:11), documented as the speaker whose sessions should match (:10). No behavior; all of it is in GetSessionsBySpeakerFilterHandler.
  • Why it's built this way: modelling the answer as a specification (rather than as a list of sessions) lets the endpoint keep its generic list-page-sort machinery and simply AND one more criterion into it, which is exactly what the paged action does.
  • Where it's used: handled by GetSessionsBySpeakerFilterHandler; injected into SessionsController (SessionsController.cs:52) and constructed in BuildPagedSessionSpecificationAsync after the SpeakerId key is removed from the filter dictionary and parsed (SessionsController.cs:113-121). The two specifications are ANDed, never substituted (:126-128); the remark at :100-104 states why: dropping the public filter for a speaker-scoped request would leak non-accepted sessions to non-privileged callers.

CalendarExportMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:14 · Level 9 · class (static, internal)

  • What it is: the shared rules of the calendar export: which sessions may appear in one, how a session becomes an IcsEvent, and how an event-local wall-clock time becomes a UTC instant.
  • Depends on: Session, Event, and SessionStatuses from the Conference domain, and IcsEvent from MMCA.Common.Shared.Calendars (:1-4). External: System.Globalization and BCL TimeZoneInfo.
  • Concept introduced: the time-zone conversion contract, and one source of truth for a visibility allow-list.
    • IcsCalendarBuilder is UTC-only by contract so it can emit Z-suffixed timestamps and skip RFC 5545's VTIMEZONE machinery entirely (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:3-8). Session times, however, are wall-clock local to the event's IANA zone. Somebody has to convert, and this mapper is where that happens, with the DST discipline stated in its own summary: invalid spring-forward times shift ahead one hour, ambiguous fall-back times resolve to the standard offset (:9-12). [Rubric §15, Best Practices & Code Quality] assesses whether a known-hard problem is handled explicitly rather than by accident: the two DST edge cases are named in the doc and the first is coded.
    • IsExportable delegates the status question wholesale to SessionStatuses.IsEligible, and the comment records why: this file used to carry a second, drifting copy of the allow-list (:21-22). [Rubric §11, Security] assesses whether a public-visibility rule has exactly one definition; a duplicated allow-list is how a status ends up publicly visible on one surface and not another.
  • Walkthrough
    • ProductId = "-//MMCA//AtlDevCon//EN" (:17), the RFC 5545 PRODID stamped on every ADC-produced calendar document.
    • IsExportable(Session) (:26-28): a property pattern requiring StartsAt and EndsAt to be non-null and IsServiceSession to be false, combined with SessionStatuses.IsEligible(session.Status) (BR-49: only Accepted or an unset status, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionStatuses.cs:54-56). The summary states the deliberate design point: this is role-independent, because the ICS document is a public-schedule artifact, so privileged callers get the same filtered export (:23-24).
    • ToIcsEvent(Session, Event, TimeZoneInfo, string? roomName) (:31-44): joins the room name and the event's VenueAddress with ", ", dropping blanks (:33-35), then builds the IcsEvent with a stable uid of the form session-{id}@atldevcon (:38), the title, both converted instants (:40-41), the description, and the joined location or null when empty (:43). The stable uid matters: calendar apps use it to de-duplicate re-imports (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsEvent.cs:9), so re-downloading the file updates the entry instead of creating a second one.
    • ToUtc(DateTime localWallClock, TimeZoneInfo) (:47-56): re-kinds the input as Unspecified (:49), and if the zone reports it as an invalid time (the hour that does not exist on a spring-forward day) adds one hour (:50-53), then constructs the DateTimeOffset with that zone's offset for the adjusted instant (:55).
  • Why it's built this way: both export handlers need identical filtering and identical time conversion. Putting them in an internal static class with no infrastructure dependencies means the rules are stated once, are unit-testable in isolation, and cannot diverge between the whole-schedule and single-session paths.
  • Where it's used: by ExportEventCalendarHandler (ExportEventCalendarHandler.cs:44, :46, :53) and ExportSessionCalendarHandler (ExportSessionCalendarHandler.cs:27, :52-53).
  • Testing: CalendarExportMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/CalendarExportMapperTests.cs:14) covers the predicate and the mapping separately: the eligible statuses as a theory (:41), a session with no start time (:45), statuses outside the allow-list as a theory (:63), a service session (:67), the wall-clock to UTC conversion (:72), the spring-forward shift (:82), the room-plus-venue location join (:96), venue only (:104), neither (:112), and the stable session-scoped uid (:120).
  • Caveats / not-in-source: the summary says ambiguous fall-back times resolve to the standard offset, but no code branches on IsAmbiguousTime: that outcome comes from TimeZoneInfo.GetUtcOffset's own behavior for an ambiguous local time (:55), not from a decision in this file. IsExportable ignores the owning event's published state entirely; that check belongs to the callers, and both perform it.

GetSessionsBySpeakerFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.GetSessionsBySpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetSessionsBySpeakerFilter/GetSessionsBySpeakerFilterHandler.cs:21 · Level 9 · class (sealed)

  • What it is: the handler for GetSessionsBySpeakerFilterQuery. It projects the session ids linked to the speaker through the SessionSpeaker join and returns a Session.Id IN (...) filter (GetSessionsBySpeakerFilterHandler.cs:21-23).
  • Depends on: IUnitOfWork (primary-constructor parameter, :22); IQueryHandler<in TQuery, TResult> closed over Result<Specification<Session, SessionIdentifierType>> (:23); InlineSpecification<TEntity, TIdentifierType> and Specification<TEntity, TIdentifierType>; Session and SessionSpeaker; Result.
  • Concept introduced: ID-list projection instead of a navigation join. Rather than expressing the rule as one LINQ expression that walks Session -> SessionSpeaker -> Speaker, the handler runs a scalar projection query first and embeds its result in the predicate. GetProjectedAsync<TResult>(select, where, asTracking, ignoreQueryFilters, cancellationToken) returns only the selected column instead of whole entities; it is declared on the IEntityQuerier<TEntity, TIdentifierType> facet (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:105-110, facet declared at :80) that IReadRepository<TEntity, TIdentifierType> composes (IRepository.cs:330-331). [Rubric §8, Data Architecture] assesses whether query shapes survive the storage topology the architecture allows. A navigation join is only translatable when both ends sit in the same physical source; an IN over materialized ids translates on every provider, which is what ADR-018 needs. The framework enforces the same constraint mechanically for declared specification classes through the SpecificationsDoNotNavigateToOtherEntities fitness rule, which instantiates parameterless specifications and inspects their Criteria (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Domain/ArchitectureRules.Specifications.cs:24, evaluation at :53, failure message at :74). [Rubric §4, DDD]: the class comment (:9-14) states the second property being bought, that the Session aggregate keeps its by-id boundary to the Speaker aggregate even while answering a cross-aggregate question.
  • Walkthrough
    1. Project the linked session ids (:30-37): resolve the read repository for SessionSpeaker and call GetProjectedAsync(ss => ss.SessionId, ss => ss.SpeakerId == query.SpeakerId, asTracking: false, cancellationToken: cancellationToken). Nothing here will be mutated, so the untracked read is what is wanted; asTracking is passed explicitly even though false is already its default (IRepository.cs:108), and a tracked read would pollute the change tracker for whatever else the request does. ignoreQueryFilters is left at its false default (IRepository.cs:109), so soft-deleted join rows are excluded by the EF global query filter rather than by a predicate term written here.
    2. Materialize and de-duplicate (:39-40): IReadOnlyList<SessionIdentifierType> ids = [.. sessionIds.Distinct()];. The inline comment (:39) gives the reason for materializing: the predicate must embed a stable collection EF can translate to IN. A lazily-enumerated source would be captured unevaluated and re-enumerated every time the criteria is applied.
    3. Wrap and return (:42-43): ids.Contains(s.Id) becomes an InlineSpecification<TEntity, TIdentifierType> returned inside Result.Success. There is no failure path: the handler cannot fail on its own terms.
  • Why it's built this way: the <para> in the class comment (:15-19) records the one behavior that is easy to get wrong downstream. A speaker with no sessions yields an empty id list, and an empty IN matches nothing, which is the correct answer; that is exactly why the caller must apply the specification rather than skip it when the list is empty. Skipping it would turn "this speaker presents nothing" into "show every session".
  • Where it's used: SessionsController's BuildPagedSessionSpecificationAsync is the only consumer (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:120-122), which ANDs the result with the public-session filter via AndSpecification<TEntity, TIdentifierType> (:126-128) and falls back to the public filter alone if this handler reports failure (:123-124).
  • Testing: GetSessionsBySpeakerFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetSessionsBySpeakerFilter/GetSessionsBySpeakerFilterHandlerTests.cs:16), five tests on the shared HandlerTestBase<THandler>: the success shape (:60), a presented session matching (:69), a session the speaker does not present being excluded (:79), a speaker with no sessions matching nothing (:89), and repeated join rows collapsing to one id (:101, the Distinct() at :40).
  • Caveats / not-in-source: the id list is materialized into the predicate, so the IN list grows with the number of sessions one speaker presents. That is naturally bounded for a conference speaker, but nothing in this file enforces a bound.

ExportEventCalendarHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandler.cs:15 · Level 10 · class (sealed)

  • What it is: the read use case that turns a published event into one .ics document: every exportable session becomes one VEVENT with its room in the location field.
  • Depends on: IUnitOfWork by primary constructor (:15-16), CalendarExportMapper, IcsCalendarBuilder, the Event and Session aggregates, and Result / Error. It implements IQueryHandler<ExportEventCalendarQuery, Result<string>> (:17).
  • Concept introduced: trusting a write-side invariant on the read path. Time zones are validated on write by EventInvariants.EnsureTimeZoneIsValid (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:82), and this handler takes that guarantee at face value: TimeZoneInfo.FindSystemTimeZoneById(@event.TimeZone) is called bare, with no try and no fallback (:41). The comment above it states the policy in full: because the invariant guards every write path a stored id always resolves, so there is no fallback and an unresolvable id is a data defect that must surface (:39-40). [Rubric §29, Resilience & Business Continuity] assesses the degradation policy on a failure path. The choice here is deliberately not to degrade: silently exporting a schedule shifted into the wrong zone would hand attendees wrong times, so the handler prefers a loud failure over a plausible-looking wrong answer. [Rubric §11, Security] assesses information disclosure on an anonymous endpoint: an unpublished or unknown event returns Error.NotFound tagged with source and target (:28-32), so the response cannot distinguish "does not exist" from "not published yet".
  • Walkthrough
    • Load the Event by id with nameof(Event.Rooms) included (:25-27). The comment explains the include rather than a separate repository call: rooms are children of the Event aggregate and have no repository of their own (:24), which is aggregate-boundary discipline in practice.
    • Guard: null or not IsPublished returns the NotFound error (:28-32).
    • Load every session for the event with no includes (:34-36), untracked by the repository default (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:85-92, asTracking defaulting to false at :90), then build a room-id to room-name dictionary from the loaded rooms (:37).
    • Resolve the time zone with the no-fallback call described above (:39-41).
    • Project: filter by CalendarExportMapper.IsExportable (:44), order by StartsAt (:45), map each session through ToIcsEvent, passing the room name only when the session has a RoomId the dictionary resolves (:46-50).
    • Build and return: IcsCalendarBuilder.Build(CalendarExportMapper.ProductId, entries, DateTimeOffset.UtcNow) wrapped in Result.Success (:53-54).
  • Why it's built this way: the filtering and conversion rules live in CalendarExportMapper, so this handler is only orchestration: load, guard, project, serialize. Ordering by StartsAt before serializing means the document reads chronologically for any client that renders it as a list, since IcsCalendarBuilder emits the entries in the order the caller supplies them (MMCA.Common/Source/Core/MMCA.Common.Shared/Calendars/IcsCalendarBuilder.cs:20, loop at :34-37).
  • Where it's used: injected into EventsController as IQueryHandler<ExportEventCalendarQuery, Result<string>> (EventsController.cs:55), invoked from GET Events/{id}/ics (:160), which returns the string as a text/calendar file named event-{id}.ics (:163).
  • Testing: ExportEventCalendarHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/ExportEventCalendarHandlerTests.cs:16), three tests: an unknown event as NotFound (:61), an unpublished event as NotFound (:74), and a published event producing an ICS document with converted times (:87).
  • Caveats / not-in-source: the handler loads every session on the event with no paging or cap, so document size scales with the schedule. DateTimeOffset.UtcNow is read inline rather than through an injected clock (:53), so the emitted DTSTAMP differs per call even though IcsCalendarBuilder is otherwise deterministic for identical inputs. A session whose RoomId is not among the event's loaded rooms exports silently with no room in its location. And per the policy above, a stored time-zone id the host cannot resolve throws TimeZoneNotFoundException out of this handler rather than degrading; nothing in this file catches it.

ExportSessionCalendarHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.ExportCalendar · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandler.cs:16 · Level 10 · class (sealed)

  • What it is: the read use case behind the single-session add-to-calendar button: one session, one VEVENT, one .ics document.
  • Depends on: the same set as its event-wide twin: IUnitOfWork (:16-17), CalendarExportMapper, IcsCalendarBuilder, Session, Event, Result / Error. It implements IQueryHandler<ExportSessionCalendarQuery, Result<string>> (:18).
  • Concept introduced: the two-hop public-read guard. The interesting difference from ExportEventCalendarHandler is that a session id alone does not establish public visibility: the session must itself be exportable and its owning event must be published. This handler checks both, in that order, and answers NotFound for either failure (:27-31, :37-41). The summary states the intent plainly: everything else is NotFound so the endpoint leaks nothing about unpublished content (:13-14). [Rubric §11, Security] assesses whether an anonymous endpoint can be used to probe for hidden content: a declined session and a session inside an unpublished event are indistinguishable from one that does not exist. [Rubric §1, SOLID] assesses reuse over duplication: the filtering rule itself is not restated here, it is the same IsExportable predicate the event-wide export applies.
  • Walkthrough
    • Load the session by id, no includes (:25-26); guard on null or !CalendarExportMapper.IsExportable(session) (:27-31).
    • Load the owning Event with Rooms included via session.EventId (:34-36), with the same aggregate-child note as the twin (:33); guard on null or unpublished (:37-41).
    • Resolve the room name by scanning the event's loaded rooms for session.RoomId, null when the session has no room (:43-45).
    • Resolve the time zone with the same no-fallback call and the same comment recording why (:47-49).
    • Build a one-element calendar with a collection expression (:51-54) and return it as a success (:56).
  • Why it's built this way: it is a deliberate near-twin of the event-wide handler rather than a shared code path with a nullable session id, because the guard order differs (session first, then event) and the room lookup is a scan rather than a dictionary. Everything genuinely shared, the export predicate, the PRODID, the IcsEvent mapping, and the time conversion, already lives once in CalendarExportMapper.
  • Where it's used: injected into SessionsController as IQueryHandler<ExportSessionCalendarQuery, Result<string>> (SessionsController.cs:53), invoked from GET Sessions/{id}/ics (:267), which returns text/calendar named session-{id}.ics (:270).
  • Testing: ExportSessionCalendarHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/ExportCalendar/ExportSessionCalendarHandlerTests.cs:16), four tests that walk the two-hop guard: an unknown session (:53), a declined session (:66), a session inside an unpublished event (:79), and the success path returning an ICS document with the room in the location (:95).
  • Caveats / not-in-source: as with the twin, DateTimeOffset.UtcNow is read inline (:54) rather than injected, and an unresolvable stored time-zone id throws out of the handler rather than degrading. The two time-zone resolutions in this folder are separate copies with nothing shared between them, so a change to that policy has to be made in both handlers.

GetPublicSessionFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:20 · Level 10 · class (sealed)

  • What it is: the handler for GetPublicSessionFilterQuery and the definitive statement of BR-132 / BR-49 in code. It resolves the published Event ids and returns a Session.EventId IN (...) filter ANDed with the status allow-list (GetPublicSessionFilterHandler.cs:20-22).
  • Depends on: IUnitOfWork (:21); CrossSourceSpecification; PublicSessionStatusSpecification (for its static StatusCriteria); Session and Event; Specification<TEntity, TIdentifierType>; Result; IQueryHandler<in TQuery, TResult> (:22).
  • Concept introduced: composing a filter across two data sources. CrossSourceSpecification exists because a predicate like s => s.Event.IsPublished is not translatable when principal and dependent may live in different physical sources (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:9-21). BuildAsync runs a scalar projection against the principal's own source (:54-57), materializes the keys once (:59-60), then builds Enumerable.Contains(keys, dependent.ForeignKey) as an expression tree (:74-79) and ANDs the optional local predicate onto it after rebinding its parameter, deliberately avoiding Expression.Invoke so the combined predicate stays translatable on every provider (:66-91, the rebinding at :86-87). [Rubric §3, Clean Architecture] assesses whether infrastructure concerns stay out of the application layer. The handler expresses a business rule and hands the storage problem to a framework helper; it names no provider, no table, and no SQL. [Rubric §15, Best Practices & Code Quality]: the status leg is not written here. It is PublicSessionStatusSpecification's StatusCriteria (:34), the same static expression the visible-session id resolver passes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:68), so the session list and every derived read cannot drift apart. That expression is s => s.Status == null || s.Status == SessionStatuses.Accepted and compares against SessionStatuses.Accepted rather than calling SessionStatuses's IsEligible, because compiled code does not translate to SQL (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:12-19, :23-24).
  • Walkthrough
    1. Build the cross-source specification (:29-36): one call to CrossSourceSpecification.BuildAsync<Session, SessionIdentifierType, Event, EventIdentifierType> with principalPredicate: e => e.IsPublished (BR-108), dependentForeignKey: s => s.EventId, and localPredicate: PublicSessionStatusSpecification.StatusCriteria (BR-49). The type arguments pin the direction of the relationship: Session is the dependent being filtered, Event the principal being resolved.
    2. Return (:38): Result.Success(specification). As with its siblings there is no failure branch.
  • Why it's built this way: the whole handler is two statements because the reusable mechanics were pushed into the framework. What stays local is the pair of business predicates, which is the part that can change. The rule is enforced at the application layer rather than in the controller so that every caller of the query gets it, including the ones added later.
  • Where it's used: SessionsController via its GetReadSpecificationAsync override (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:77-87), which reaches the unpaged list, the paged list, the lookup, and the by-id read. The lookup action is worth reading: it is an attribute-only passthrough to the framework base action (SessionsController.cs:214-217) precisely because the read hook already scopes it, and a lookup endpoint would otherwise be a side channel listing the sessions the list and detail endpoints already hide (:204-208).
  • Testing: GetPublicSessionFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandlerTests.cs:12), seven test methods asserted against the produced criteria rather than against the handler's internals: the success shape (:72), the public statuses matching for a published event (:84, a theory over Accepted and null), the non-public statuses excluded (:101, a theory over the non-eligible Sessionize values), a session of an unpublished event excluded (:111), no published events matching nothing (:121), the principal predicate selecting only published events (:139), and the cancellation token reaching the event query (:162).
  • Caveats / not-in-source: CrossSourceSpecification materializes the matching principal keys into the predicate, and its own note (CrossSourceSpecification.cs:17-20) scopes the technique to small or bounded principal sets, the "published events" shape. Nothing in this handler bounds that set; it is bounded in practice by how many events a conference publishes. Note also that the controller maps a failed Result to null, meaning no filter (SessionsController.cs:86), which would widen the read rather than narrow it; nothing in this handler can produce that failure today, so the exposure is latent rather than live.

GetPublicSessionCategoryItemFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionCategoryItemFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandler.cs:16 · Level 11 · class (sealed)

  • What it is: the handler for GetPublicSessionCategoryItemFilterQuery. It asks PublicConferenceVisibility for the visible session ids and wraps them in a SessionCategoryItem.SessionId IN (...) specification (GetPublicSessionCategoryItemFilterHandler.cs:16-18).
  • Depends on: IUnitOfWork (:17, injected only to hand on to the resolver); PublicConferenceVisibility; InlineSpecification<TEntity, TIdentifierType>; SessionCategoryItem; Result. Implements IQueryHandler<in TQuery, TResult> to Result<Specification<SessionCategoryItem, SessionCategoryItemIdentifierType>> (:18).
  • Concept: none new; the derived junction filter is taught under GetPublicSessionSpeakerFilterHandler, and this is the category-assignment instance of the same shape. It calls the identical resolver method its speaker-side twin does and restates nothing of the rule. [Rubric §15, Best Practices & Code Quality]: the junction cannot drift away from the entity whose visibility it follows, because it holds no copy of that entity's rule. [Rubric §8, Data Architecture]: the answer arrives as an id list turned into Contains, not a navigation join, so the criteria stays translatable on any provider (ADR-018).
  • Walkthrough
    1. Resolve the visible session ids (:25-27): PublicConferenceVisibility.GetVisibleSessionIdsAsync(unitOfWork, cancellationToken). Inside the resolver that is the same CrossSourceSpecification.BuildAsync call GetPublicSessionFilterHandler makes, over Session and Event with principalPredicate: e => e.IsPublished, dependentForeignKey: s => s.EventId, and localPredicate: PublicSessionStatusSpecification.StatusCriteria (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:63-70). The resolver then hands that specification straight to the repository's spec-taking projection overload, ListAsync(specification, s => s.Id, cancellationToken) (PublicConferenceVisibility.cs:77-79; the overload is declared at MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:279-282 and projects server side, after the specification's ordering and paging, :264-273). The comment above the call explains why passing a specification is enough (PublicConferenceVisibility.cs:72-74): a plain specification contributes its Criteria and nothing else, so this is the untracked, soft-delete-filtered read the explicit GetProjectedAsync arguments would otherwise have to spell out. The comment at :61-62 states the property being bought: the same helper and the same criteria the public-session read filter uses, so a session hidden from the session list can never stay reachable through a junction read.
    2. Wrap and return (:29-31): sci => sessionIds.Contains(sci.SessionId) inside an InlineSpecification<TEntity, TIdentifierType>, returned as Result.Success. No failure path.
  • Why it's built this way: the doc comment states the intent directly (:10-15): a junction row follows the visibility of its parent session (BR-49). Deriving that answer instead of copying the session rule keeps one definition of "publicly visible session" behind the session list, the session-speaker join, and this category-assignment join.
  • Where it's used: SessionCategoryItemsController's GetReadSpecificationAsync override (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionCategoryItemsController.cs:73-83) is the only consumer, and from there it reaches the unpaged list (:85-88), paged list (:95-98), lookup (:114-117), and by-id (:122-125) reads. Note that the class-level [HasPermission(ConferencePermissions.SessionsManage)] (:47) is overridden per action by [AllowAnonymous] (:86, :96, :115, :123), which is exactly why the handler has to carry the visibility rule itself.
  • Testing: GetPublicSessionCategoryItemFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandlerTests.cs:18), four tests on the shared HandlerTestBase<THandler>: the success shape (:56), a row whose parent session is visible (:65), a row whose parent session is hidden (:75), and a world with no visible sessions at all (:85). The fixture mocks the two reads the resolver actually performs: GetProjectedAsync on the Event repository (:29-36) and the spec-taking ListAsync on the Session repository (:46-51), whose comment notes that the resolver hands the session read a specification rather than an unwrapped predicate. One further detail is a property of the entity rather than of the test: SessionCategoryItem.SessionId is get-only and written by EF (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionCategoryItem.cs:24), so a row built in memory carries the default id, and the fixture uses default as its row session id (:20-21).
  • Caveats / not-in-source: as on the other junction reads, the controller maps a failed Result to null, meaning no filter (SessionCategoryItemsController.cs:82), which would widen the read rather than narrow it; nothing in this handler can produce that failure today, so the exposure is latent rather than live. The resolved id list is also materialized into the predicate, so the IN list grows with the number of publicly visible sessions across every published event, and nothing in this file bounds it.

GetPublicSessionSpeakerFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.GetPublicSessionSpeakerFilter · MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandler.cs:15 · Level 11 · class (sealed)

  • What it is: the handler for GetPublicSessionSpeakerFilterQuery. It resolves the visible session ids and returns a SessionSpeaker.SessionId IN (...) specification, so a join row is readable exactly when its parent session is (GetPublicSessionSpeakerFilterHandler.cs:15-17).
  • Depends on: IUnitOfWork (:16); PublicConferenceVisibility; InlineSpecification<TEntity, TIdentifierType>; SessionSpeaker; Result; IQueryHandler<in TQuery, TResult> (:17).
  • Concept introduced: the derived junction filter. The rule this handler enforces is not its own. It is one call to a shared resolver, PublicConferenceVisibility.GetVisibleSessionIdsAsync (:24-26), followed by a Contains over the returned ids. Nothing about "Accepted or unset status, inside a published event" appears in this file, which is the point: the definition lives once (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:57-82) and every read that must respect it derives from that one definition. [Rubric §11, Security] assesses whether a visibility rule holds on every surface that can reach the protected data. The summary and remarks on the resolver (PublicConferenceVisibility.cs:10-27) state the invariant: one definition of "publicly visible" backs the session, speaker, and junction read filters, so closing a leak in one place closes it everywhere, and everything is expressed as scalar id projections rather than navigation joins so the criteria stay translatable on any engine. [Rubric §1, SOLID]: this is the single-responsibility split in miniature. The resolver decides who is visible; the handler decides how that answer is shaped for one entity.
  • Walkthrough
    1. Resolve (:24-26): GetVisibleSessionIdsAsync(unitOfWork, cancellationToken), which internally builds the cross-source session specification (PublicConferenceVisibility.cs:63-70) and projects the ids matching it through the spec-taking ListAsync overload (:75-79).
    2. Wrap and return (:28-30): ss => sessionIds.Contains(ss.SessionId) inside an InlineSpecification<TEntity, TIdentifierType>, returned as Result.Success. The handler has no failure branch and no conditional logic at all.
  • Why it's built this way: the alternative, restating the status and published-event rules against navigation properties, would both duplicate the rule and produce criteria a non-relational provider could not translate. Two round trips (ids, then the filtered read) buy one rule and portable criteria.
  • Where it's used: SessionSpeakersController's GetReadSpecificationAsync override (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSpeakersController.cs:73-83) is the only consumer, feeding the unpaged list (:85-88), paged list (:95-98), lookup (:114-117), and by-id (:122-125) reads, each [AllowAnonymous] (:86, :96, :115, :123) under the class-level permission requirement (:47). The doc comment on the override records what the hook buys (:66-71): every read action is scoped from one place, an excluded row is a 404 rather than a redacted record, and the actions below are attribute-only passthroughs.
  • Testing: GetPublicSessionSpeakerFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandlerTests.cs:18), four tests mirroring the category-item twin one for one: the success shape (:56), a row of a visible session matching (:65), a row of a hidden session excluded (:75), and no visible sessions matching nothing (:85).
  • Caveats / not-in-source: identical to the category-item handler. A failed Result becomes null in the controller, meaning no filter (SessionSpeakersController.cs:82), and the materialized id list is unbounded in this file.

GetPublicSpeakerCategoryItemFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetPublicSpeakerCategoryItemFilter · MMCA.ADC.Conference.Application/Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterQuery.cs:8 · Level 0 · record

  • What it is: the parameterless query that asks for the read filter applied to the speaker-to-category-item junction. It carries no data at all; it exists purely as the typed key that resolves the matching handler out of DI.
  • Depends on: nothing first-party. It is a bare public sealed record with no positional parameters and no members (GetPublicSpeakerCategoryItemFilterQuery.cs:8).
  • Concept introduced: the filter query as a DI lookup token. Most CQRS messages in this module carry a payload. This one carries none, because the answer depends only on ambient data (which events are published, which sessions are on the BR-49 allow-list) and not on anything the caller can supply. Declaring it as a type anyway is what lets a controller inject IQueryHandler<GetPublicSpeakerCategoryItemFilterQuery, ...> and get the visibility rule through the same pipeline as every other read, rather than calling a static helper directly from the API layer. [Rubric §6, CQRS & Event-Driven] assesses whether reads are expressed as explicit, individually resolvable messages: even a zero-argument rule gets its own query type here. [Rubric §11, Security] assesses where authorization data is decided: the rule is derived server-side from published state, so there is no request field an anonymous caller could tamper with.
  • Walkthrough: one line of code (:8). The XML doc above it (:3-7) is the load-bearing part: it records why the junction needs its own filter at all, namely that without it the join endpoints would list the categories of a hidden speaker (including the BR-66 locality assignments) and leak that speaker's existence even though the speaker row itself is filtered out.
  • Why it's built this way: a record with no parameters still gets value equality and a compiler-generated ToString, and costs nothing to allocate per request. Keeping it distinct from GetPublicSpeakerFilterQuery means the two filters can diverge later (the junction read has no event context to scope by) without either handler growing a mode flag.
  • Where it's used: constructed by SpeakerCategoryItemsController inside its GetReadSpecificationAsync override (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:79) and answered by GetPublicSpeakerCategoryItemFilterHandler.

GetPublicSpeakerFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetPublicSpeakerFilter · MMCA.ADC.Conference.Application/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterQuery.cs:20 · Level 0 · record

  • What it is: the query that asks for the public-speaker read filter (BR-239), optionally narrowed to one event. It is a single-parameter record whose only field is a nullable event id that defaults to null.
  • Depends on: the module alias EventIdentifierType (int for Conference, declared in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8). No other first-party types.
  • Concept introduced: the optional scope parameter. The same rule has to answer two different questions: "which speakers are public anywhere" and "which speakers are public on this event". Rather than two query types, one nullable parameter distinguishes them, and the default = null (GetPublicSpeakerFilterQuery.cs:20) means the un-scoped call site reads as new GetPublicSpeakerFilterQuery(). [Rubric §9, API & Contract Design] assesses contract expressiveness: the nullable is documented per-parameter (:15-19) as "the paged list has one, everything else passes none", so the two modes are part of the published contract rather than folklore. [Rubric §11, Security] as with the junction query: the id only narrows the rule, it can never widen it, because an unpublished or unknown scoped event resolves to an empty visible set (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:113-120).
  • Walkthrough: public sealed record GetPublicSpeakerFilterQuery(EventIdentifierType? EventId = null) (:20). The <remarks> block (:9-14) explains the shape the handler will return: Speaker carries no status or event column of its own, so the rule cannot be expressed as a property comparison and is instead resolved into an id list and returned as a Speaker.Id IN (...) criteria, following the BR-132 precedent. That keeps the criteria free of navigation joins, so it stays translatable on any engine and Speaker keeps its by-id boundary to the Session and Event aggregates.
  • Why it's built this way: making the scope optional rather than required is what lets one handler serve the paged list, the lookup, GetById, and the junction reads. The alternative (a required id plus a sentinel) would have pushed the "no context" case into every caller.
  • Where it's used: constructed by SpeakersController in BuildPublicSpeakerSpecificationAsync (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:92) and answered by GetPublicSpeakerFilterHandler.

ISessionFieldsRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/ISessionFieldsRequest.cs:13 · Level 0 · interface

  • What it is: the read-only shape of the seven session fields that the create request and the update request validate identically. Both request records implement it, which is what lets one generic validator declare the shared rule list exactly once.
  • Depends on: nothing. Seven get-only properties over BCL string and string?, no usings at all (ISessionFieldsRequest.cs:1).
  • Concept introduced: the validation-shape interface. A request record is a transport DTO, not a domain type, so it has no natural base class to hang shared rules on, and the create and update requests are genuinely different records (create carries Id and implements ICreateRequest and ICacheInvalidating, MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequest.cs:11; update carries only EventId plus its own fields, .../Update/SessionUpdateRequest.cs:6). Extracting just the overlapping surface into an interface gives FluentValidation a generic constraint to work against: SessionFieldRules<T> is declared where T : ISessionFieldsRequest and can therefore write p => p.Title once for both records. [Rubric §1, SOLID] assesses interface segregation: this interface deliberately holds only the fields both operations validate the same way, nothing more. [Rubric §15, Best Practices & Code Quality] assesses whether adding a field is one edit or several: a new shared session field is added here, to the two records, and to one rule list, instead of to two independently maintained validators.
  • Walkthrough: Title is non-nullable (:16) and is the only required member; Description (:19), Status (:22), LiveUrl (:25), RecordingUrl (:28), AccessibilityInfo (:31), and ResourceLinks (:34) are all string?. Every member is get-only, so implementing it costs an init-only property and nothing else, and no consumer can mutate a request through the interface.
  • Why it's built this way: the <remarks> block (:8-12) records what is deliberately absent, which is the more interesting half. Fields validated by only one of the two operations stay off the interface on purpose: EventId is on both request records, but it is validated only on create, because BR-140 makes the owning event immutable and the update request carries it solely so UpdateSessionHandler can reject a change (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:44-52). Likewise StartsAt, EndsAt, and RoomId are absent because their rule is not a field constraint at all: it is the cross-aggregate room and slot check in SessionRoomScheduling. The interface is the boundary between "same rule both ways" and "operation-specific", and keeping that boundary honest is what stops the shared rule list from growing When(...) clauses.
  • Where it's used: implemented by SessionCreateRequest (SessionCreateRequest.cs:11) and SessionUpdateRequest (SessionUpdateRequest.cs:6), and used as the generic constraint on SessionFieldRules<T> (MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:113).

SessionEventIdRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:24 · Level 1 · class (sealed, generic)

  • What it is: the reusable rule fragment asserting that a session request actually names a parent event. It is the only fragment in this file that validates a non-string field, and the only one that pins its own error code.
  • Depends on: RequiredIdRules<T, TId> (its base class, SessionValidationRules.cs:25), System.Linq.Expressions.Expression<Func<T, EventIdentifierType>> for the property selector (:1,27), and the module alias EventIdentifierType.
  • Concept introduced: the parameterized rule fragment, the shape every rule in this file follows. Rather than repeating RuleFor(x => x.EventId).NotEmpty() inside each command validator, the constraint lives once in a small generic AbstractValidator<T> subclass whose constructor takes the property selector; a concrete validator then folds it in with FluentValidation's Include(...). Because the fragment is generic in T, the same type validates the create request, the update request, or any future import request, each pointing the selector at its own property. The framework supplies the bases these fragments specialize: RequiredIdRules<T, TId> here, and RequiredStringRules<T> or OptionalStringRules<T> for the seven string fragments below. [Rubric §1, SOLID]: one fragment, one field contract, composed rather than copied. [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are declared once and applied consistently at every entry path.
  • Walkthrough: the expression-bodied constructor (:27-28) forwards base(selector, "an Event for the Session", "Session.EventId.Required"). The base (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:142-148) chains a single NotEmpty() clause with the message You must specify {fieldName} and applies the optional error code (:145-147), which is why the field phrase carries its own article: the caller supplies "an Event for the Session" and the base interpolates it verbatim. Because EventIdentifierType is int (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8), FluentValidation's NotEmpty tests against default(int), so the rule rejects a missing or zero event id; the base's remarks (CommonValidationRules.cs:133-139) note that the same check rejects Guid.Empty for a GUID key, which is what makes one base serve both id shapes.
  • Why it's built this way: the stable error code is what the API error-mapping layer keys on, so it is part of the contract and not just display text. Keeping the event-id check as its own fragment (instead of folding it into the shared SessionFieldRules<T> list) is what lets the update path deliberately omit it: an update request also carries EventId, but re-parenting is rejected by the handler under BR-140 with the Session.EventId.Immutable error (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:44-52), not by the validator.
  • Testing: SessionValidationRulesTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionValidationRulesTests.cs:8) pins both outcomes directly on the fragment: a supplied event id passes (:48) and default(int) fails (:55).
  • Where it's used: Included by SessionCreateRequestValidator as its create-only delta (MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestValidator.cs:16), directly beside the comment explaining the omission (:13-15). It is deliberately absent from SessionUpdateRequestValidator, whose whole body is one Include of the shared field rules (SessionUpdateRequestValidator.cs:12).

SessionAccessibilityInfoRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:86 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment bounding a session's optional accessibility-info text to 500 characters.
  • Depends on: OptionalStringRules<T> (its base class, SessionValidationRules.cs:87) and SessionInvariants for the bound (:4,90).
  • Concept reinforced: the parameterized rule fragment introduced by SessionEventIdRules<T>, in its shortest possible form. Deriving from the framework's OptionalStringRules<T> means the subclass declares nothing but a base(...) call: the base applies MaximumLength and no NotEmpty (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:55-57), which is exactly the contract a nullable field needs. [Rubric §1, SOLID] and [Rubric §24, Forms/Validation/UX Safety].
  • Walkthrough: sealed class SessionAccessibilityInfoRules<T> : OptionalStringRules<T> (:86-87); the constructor (:89) forwards base(selector, "Accessibility Info", SessionInvariants.AccessibilityInfoMaxLength) (:90). The label "Accessibility Info" is what appears in the generated length message; the bound is 500, reached through SessionInvariants (MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:25) which itself forwards to SessionDTO.AccessibilityInfoMaxLength (MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:27).
  • Why it's built this way: the number lives on the DTO, the lowest layer the UI can also reach, and the domain invariant re-exports it (SessionInvariants.cs:7-11). One constant therefore feeds the Blazor markup, this validator, the aggregate's own EnsureStringMaxLength guard (SessionInvariants.cs:80), and the EF column width, so a limit change cannot drift between layers.
  • Where it's used: Included by SessionFieldRules<T> (SessionValidationRules.cs:122), which is the single list both the create and the update validator fold in.

SessionDescriptionRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:36 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment bounding a session's optional description to 4000 characters, the widest text bound in the session family.
  • Depends on: OptionalStringRules<T> (:37) and SessionInvariants (:40).
  • Concept reinforced: identical shape to SessionAccessibilityInfoRules<T>: nullable field, MaximumLength only, no NotEmpty.
  • Walkthrough: the constructor (:39) forwards base(selector, "Session Description", SessionInvariants.DescriptionMaxLength) (:40); DescriptionMaxLength is 4000 (MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:21, re-exported at MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:19), the same constant the aggregate's own optional-text guard cites (SessionInvariants.cs:76).
  • Where it's used: Included by SessionFieldRules<T> (SessionValidationRules.cs:118).

SessionLiveUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:61 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment bounding a session's optional live-stream URL to 2000 characters. Length only: it deliberately does not check that the value is a well-formed URL.
  • Depends on: OptionalStringRules<T> (:62) and SessionInvariants (:65).
  • Concept introduced: the deliberately weak format rule. Every other URL-ish field in a codebase invites a Uri.TryCreate check, and this one refuses it on purpose. The class doc (:55-60) records why: the value is stored as an opaque string for Sessionize compatibility, so whatever the import writes has to round-trip unchanged. Validating a format the upstream source does not guarantee would reject real data. The contrast is visible one file over in the framework: AbsoluteUrlRules<T> exists and adds an absolute http/https check on top of the same length bound (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:85-94), so choosing OptionalStringRules<T> here is a decision, not an omission. [Rubric §24, Forms/Validation/UX Safety] assesses fitness of validation, not quantity: a rule that would fail on legitimate imported values is worse than no rule. [Rubric §26, Front-End Security] is the counterweight worth naming, and the framework doc states it plainly (CommonValidationRules.cs:77-83): a bounded string still accepts javascript: and data: values, so anything that renders this field as a link owns its own scheme checking.
  • Walkthrough: the constructor (:64) forwards base(selector, "Live URL", SessionInvariants.LiveUrlMaxLength) (:65); LiveUrlMaxLength is 2000 (MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:33, re-exported at MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:31). The aggregate's own guard is length-only too, with the same stated reason (SessionInvariants.cs:57,78).
  • Caveats / not-in-source: the fragment guarantees length only. Whether any specific consumer sanitizes the value before rendering is not determinable from this file.
  • Where it's used: Included by SessionFieldRules<T> (SessionValidationRules.cs:120).

SessionRecordingUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:74 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment bounding a session's optional recording URL to 2000 characters, length only.
  • Depends on: OptionalStringRules<T> (:75) and SessionInvariants (:78).
  • Concept reinforced: the same deliberately weak format rule as SessionLiveUrlRules<T>, with the identical rationale recorded in its own doc block (:68-73): the value is an opaque Sessionize-compatible string.
  • Walkthrough: the constructor (:77) forwards base(selector, "Recording URL", SessionInvariants.RecordingUrlMaxLength) (:78); RecordingUrlMaxLength is 2000 (MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:36, re-exported at MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:34).
  • Where it's used: Included by SessionFieldRules<T> (SessionValidationRules.cs:121).

SessionResourceLinksRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:98 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment bounding a session's optional resource-links text to 2000 characters.
  • Depends on: OptionalStringRules<T> (:99) and SessionInvariants (:102).
  • Concept reinforced: identical shape to SessionAccessibilityInfoRules<T>.
  • Walkthrough: the constructor (:101) forwards base(selector, "Resource Links", SessionInvariants.ResourceLinksMaxLength) (:102); ResourceLinksMaxLength is 2000 (MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:30, re-exported at MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:28). The field is a single free-text column rather than a structured collection, so the fragment can only bound its total length.
  • Where it's used: Included by SessionFieldRules<T> (SessionValidationRules.cs:123).

SessionStatusRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:48 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment bounding a session's optional status string to 100 characters.
  • Depends on: OptionalStringRules<T> (:49) and SessionInvariants (:52).
  • Concept reinforced: identical shape to SessionAccessibilityInfoRules<T>, with one thing worth noticing by its absence. Status drives the BR-49 public-visibility allow-list that GetPublicSpeakerFilterHandler depends on, yet this fragment does not constrain the value to a known set: it checks length and nothing else. The status vocabulary comes from Sessionize, so an unrecognized value is possible input rather than a bug, and the allow-list is applied at read time (through PublicSessionStatusSpecification) instead of being enforced at write time. [Rubric §24, Forms/Validation/UX Safety] and [Rubric §8, Data Architecture]: the column stores whatever the upstream source calls the status, and meaning is assigned by the reader.
  • Walkthrough: the constructor (:51) forwards base(selector, "Session Status", SessionInvariants.StatusMaxLength) (:52); StatusMaxLength is 100 (MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:24, re-exported at MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:22).
  • Where it's used: Included by SessionFieldRules<T> (SessionValidationRules.cs:119).

SessionTitleRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:13 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment for a session title: non-empty and bounded to 500 characters. It is the one string fragment in this file with a required field, and the first type declared in it.
  • Depends on: RequiredStringRules<T> (its base class, SessionValidationRules.cs:14) and SessionInvariants (:17).
  • Concept reinforced: the parameterized fragment from SessionEventIdRules<T>, specialized against the required string base rather than the optional one. RequiredStringRules<T> chains NotEmpty() then MaximumLength(maxLength) with generated messages built from the field label (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:43-46), so choosing the base is how a fragment declares required-versus-optional. [Rubric §1, SOLID] and [Rubric §24, Forms/Validation/UX Safety].
  • Walkthrough: sealed class SessionTitleRules<T> : RequiredStringRules<T> (:13-14); the constructor (:16) forwards base(selector, "Session Title", SessionInvariants.TitleMaxLength) (:17). TitleMaxLength is 500 (MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:18, re-exported at MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:16), the same constant the aggregate's own title guard cites (SessionInvariants.cs:49-52).
  • Caveats / not-in-source: unlike SessionEventIdRules<T>, none of the seven string fragments in this file pass an error code, even though both framework bases accept an optional one and apply it to every rule they declare (CommonValidationRules.cs:43,55, via OptionalErrorCodeExtensions at :30-32). Their failures therefore surface with generated messages and FluentValidation's default codes, not the stable Session.<Field>.<Reason> codes the domain invariants use (SessionInvariants.cs:51-52,76-81).
  • Testing: SessionValidationRulesTests covers the fragment's three outcomes directly: a valid title passes (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionValidationRulesTests.cs:25), an empty one fails (:32), and one over the bound fails (:40).
  • Where it's used: Included by SessionFieldRules<T> (SessionValidationRules.cs:117), the first entry in the shared list.

SessionFieldRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:111 · Level 8 · class (sealed, generic)

  • What it is: the one composite that folds the seven shared session field fragments into a single includable validator, generic over any request implementing ISessionFieldsRequest. It is what a concrete request validator includes instead of listing seven rules of its own.
  • Depends on: ISessionFieldsRequest as its generic constraint (SessionValidationRules.cs:113), FluentValidation's AbstractValidator<T> (:112), and all seven sibling fragments in this file: SessionTitleRules<T>, SessionDescriptionRules<T>, SessionStatusRules<T>, SessionLiveUrlRules<T>, SessionRecordingUrlRules<T>, SessionAccessibilityInfoRules<T>, and SessionResourceLinksRules<T>.
  • Concept introduced: the composite rule set, and validation-by-delta. The fragments below it each own one field; this type owns the answer to "which fields does every session write validate the same way". Because it is constrained to the interface rather than to a concrete record, it can bind selectors like p => p.Title (:117) with no knowledge of which request it is validating, and FluentValidation's Include flattens the whole tree into the parent validator's rule set so the caller still gets one flat list of failures. What that buys is a readable delta at each call site: SessionUpdateRequestValidator is now a single expression-bodied Include (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequestValidator.cs:12), and SessionCreateRequestValidator is that same Include plus exactly one create-only rule (.../Create/SessionCreateRequestValidator.cs:11,16). A reader can see the difference between the two operations in two lines. [Rubric §1, SOLID] assesses composition over repetition: each fragment stays single-purpose and this type is the only place their order and membership is decided. [Rubric §5, Vertical Slice] assesses whether a slice owns its own request contract: the create and update slices keep their own validators, and share only what is genuinely identical. [Rubric §15, Best Practices & Code Quality] assesses the cost of a change: adding a shared field is one Include here, not two edits kept in sync by discipline.
  • Walkthrough: the class declaration (:111-113) is sealed class SessionFieldRules<T> : AbstractValidator<T> where T : ISessionFieldsRequest. The parameterless constructor (:115-124) makes seven Include calls in field order: title (:117), description (:118), status (:119), live URL (:120), recording URL (:121), accessibility info (:122), and resource links (:123). Each one constructs the matching fragment with a lambda selector off the interface, so the selectors compile against T and FluentValidation resolves the property name for the message from the expression.
  • Why it's built this way: the class doc (:105-109) states the contract in one sentence, that this is the rule set the create and the update request share and that each concrete validator adds only the rules its own operation needs. Notice what is not here: EventId (create-only, then immutable under BR-140) and the scheduling fields (StartsAt, EndsAt, RoomId), whose rule is cross-aggregate and lives in SessionRoomScheduling where it can query. Keeping the composite to exactly the identical rules is what avoids the usual failure mode of a shared validator, a growing thicket of When(request is CreateX) branches.
  • Testing: exercised through both concrete validators rather than directly: SessionCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCreateRequestValidatorTests.cs:7, ten cases) and SessionUpdateRequestValidatorTests (.../SessionUpdateRequestValidatorTests.cs:7, nine cases). Testing at the composed surface is the right level here: it proves the Include chain actually reaches each field, which testing the fragments in isolation would not.
  • Where it's used: Included by SessionCreateRequestValidator (SessionCreateRequestValidator.cs:11) and SessionUpdateRequestValidator (SessionUpdateRequestValidator.cs:12), the only two closed constructions in the module.

SessionRoomScheduling

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Validation · MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:27 · Level 9 · class (static)

  • What it is: the shared room-assignment guard for the session create and update paths. One static class holding the cross-event room check (BR-130), the SQL-translatable overlap predicate that detects a double booking, and the conflict error both paths return.
  • Depends on: IEntityReader<TEntity, TIdentifierType> for the existence probe (SessionRoomScheduling.cs:45), Session, Event and its Room children, Result and Error, plus System.Linq.Expressions (BCL) and the module aliases RoomIdentifierType and SessionIdentifierType (both int).
  • Concept introduced: the half-open interval and the honestly-documented soft guard. Two sessions conflict when they share a room and their [StartsAt, EndsAt) windows overlap; back-to-back sessions, where one ends exactly when the next starts, do not conflict. That is the whole business rule, and it falls out of the two strict comparisons in the predicate rather than needing any special case. The second, more important lesson is in the class doc (:16-25): this check is deliberately advisory. The existence probe and the insert or update that follows are separate statements, not one atomic step, so two concurrent organizer writes can both observe a free window and both commit, genuinely double-booking the room. The doc also explains why persistence cannot close the gap cheaply: the predicate spans an interval rather than a single value, and SQL Server has no range-exclusion constraint, so no unique index can express the rule. The trade-off is accepted because the create and update endpoints are organizer-only (a narrow, low-concurrency audience) and the outcome is repairable at any time by editing either session's room or slot. [Rubric §8, Data Architecture] assesses how consistency rules are enforced against the store; this is a read-then-write guard with its own limits written down instead of assumed away. [Rubric §15, Best Practices & Code Quality] and [Rubric §34, Architecture Governance & Documentation]: an accepted weakness documented at the point of use is worth more than a silent one. [Rubric §12, Performance & Scalability]: the check is one server-side existence probe, never a client-side scan of the room's schedule.
  • Walkthrough (three public members, in call order):
    • ValidateRoomAssignmentAsync (:44-81) is the entry point both handlers call. It null-guards parentEvent (:54), then short-circuits to success when no room was requested (:56-57), because an unassigned session cannot conflict with anything. It looks the room up inside the already-loaded parent event's Rooms collection, requiring it to be non-soft-deleted (:59); a room that belongs to some other event is not found there and returns Error.Validation coded Session.RoomId.CrossEvent targeting Session.RoomId (:62-67). That is BR-130, and it costs no extra query. It then short-circuits again if either end of the window is missing (:69-70): a room can be assigned without a scheduled slot. Only with a room and both times does it run the probe, repository.ExistsAsync(BuildOverlapPredicate(...)) (:74-76), returning the conflict error or success (:78-80).
    • BuildOverlapPredicate (:93-107) builds the Expression<Func<Session, bool>> the probe translates to SQL. excludeSessionId defaults to null and collapses to int.MinValue (:101), which the comment justifies: session ids are always positive (Sessionize-assigned or the reserved manual range starting at 999_999_000, MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:44), so the sentinel excludes nothing and keeps the predicate a single shape rather than two conditionally-composed ones. The predicate itself (:103-106) requires the same RoomId, Id != exclusionId, both timestamps non-null, and then the two strict comparisons s.StartsAt < endsAt && s.EndsAt > startsAt that define the half-open overlap.
    • DoubleBookedError (:116-121) returns Error.Conflict coded Session.Room.DoubleBooked, the 409-style failure the API surfaces. Exposing it as a named member means the two handlers and their tests refer to one definition of the conflict.
  • Why it's built this way: factoring the rule into a static class (rather than duplicating it in each handler, or pushing it into Session) is a direct consequence of where the data lives. The rule spans two aggregates: it needs the parent Event's rooms and it needs every other session's schedule, so no single aggregate can enforce it, and it belongs in the application layer beside the handlers that load both. It is also why these fields are absent from ISessionFieldsRequest: a rule that has to query cannot live in a FluentValidation fragment. Keeping the predicate a separate public member is what lets the update path pass excludeSessionId so a session can keep or shrink its own slot without colliding with itself.
  • Testing: SessionRoomSchedulingTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionRoomSchedulingTests.cs:12) exercises the predicate as a pure function, which is what makes the half-open rule cheap to pin down: overlapping (:54) and fully contained (:62) slots match, while back-to-back (:70), same-room-different-day (:79), different-room (:87), self-excluded (:95), and unscheduled (:104) cases do not. A final test asserts DoubleBookedError is conflict-typed (:112).
  • Caveats / not-in-source: the class doc notes that deliberate co-location (lightning talks sharing one slot) would need this check relaxed from a rejection to a warning (:13-15). No such relaxation exists in the current code.
  • Where it's used: called by CreateSessionHandler with excludeSessionId: null (MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:111-119, only when the command actually carries a room, :100) and by UpdateSessionHandler with excludeSessionId: command.Id (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:66-74), each passing its own handler name as the error source. Both load the parent event with includes: [nameof(Event.Rooms)] and asTracking: false first (CreateSessionHandler.cs:103-107, UpdateSessionHandler.cs:57-61), because that collection is what the cross-event check reads.

GetPublicSpeakerCategoryItemFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetPublicSpeakerCategoryItemFilter · MMCA.ADC.Conference.Application/Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandler.cs:17 · Level 11 · class

  • What it is: the handler that turns GetPublicSpeakerCategoryItemFilterQuery into a specification restricting the speaker-to-category-item junction to rows whose parent speaker is publicly visible (BR-239).
  • Depends on: IQueryHandler<in TQuery, TResult>, IUnitOfWork, PublicConferenceVisibility, Specification<TEntity, TIdentifierType> and InlineSpecification<TEntity, TIdentifierType>, SpeakerCategoryItem, and Result.
  • Concept introduced: a query handler whose result is a filter, not data. Every other read handler in this module returns rows or a DTO. This one returns a Specification<TEntity, TIdentifierType>, which the controller then hands to its generic query service so the framework's paging, sorting, and projection all run inside the restricted set. The visibility rule is therefore composed with the caller's own filters by the query pipeline rather than being applied afterwards in memory, which is what keeps page counts honest. [Rubric §6, CQRS & Event-Driven] assesses the read side's composability; [Rubric §11, Security] assesses that the restriction is applied at the data layer, so a caller cannot page past it; [Rubric §12, Performance & Scalability] assesses that filtering happens server-side rather than after materialization.
  • Walkthrough: the primary constructor (:17-18) injects IUnitOfWork only. The declared result type (:19) is Result<Specification<SpeakerCategoryItem, SpeakerCategoryItemIdentifierType>>. HandleAsync (:22-24) calls PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, cancellationToken: cancellationToken) (:26-28), passing no event scope, so the rule spans every published event. It wraps the resulting id list in an InlineSpecification<TEntity, TIdentifierType> whose criteria is sci => speakerIds.Contains(sci.SpeakerId) (:31-32), a SpeakerId IN (...) predicate, and returns it as a success (:30).
  • Why it's built this way: the junction row follows the visibility of its parent, so the handler reuses the one visible-speaker computation instead of re-deriving a junction-specific rule. Filtering by an id list rather than a navigation join keeps the criteria engine-portable (ADR-018) and preserves the by-id boundary between SpeakerCategoryItem and the Session and Event aggregates the rule actually reads. The query parameter is accepted and unused because the IQueryHandler<in TQuery, TResult> contract requires it, which is also why the cancellation token is passed by name (:27).
  • Testing: GetPublicSpeakerCategoryItemFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandlerTests.cs:19), four tests: the success shape (:66), a row of a visible speaker matching (:75), a row of a hidden speaker excluded (:85), and no visible speakers matching nothing (:95).
  • Caveats / not-in-source: the id list is materialized into the expression, so the generated SQL carries as many parameters as there are visible speakers. What that costs at conference scale is not determinable from this file.
  • Where it's used: injected into SpeakerCategoryItemsController (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:52) and invoked from its GetReadSpecificationAsync override (:73-83), the framework's read hook: it returns null for privileged readers (Organizer/ContentEditor, :59,76-77) so they see every row, and otherwise returns the handler's specification (:79-82). Because the hook is the base controller's, every read action is scoped from that one place and an excluded row is a 404 rather than a redacted record (:61-72), covering GetAll (:85), the paged list (:95), the lookup (:114), and GetById (:122), each [AllowAnonymous] (:86, :96, :115, :123) under the class-level [HasPermission(ConferencePermissions.SpeakersManage)] requirement (:47). Registration is convention-based: the module's ScanModuleApplicationServices<ClassReference>() call picks up every handler in the assembly (MMCA.ADC.Conference.Application/DependencyInjection.cs:133).

GetPublicSpeakerFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetPublicSpeakerFilter · MMCA.ADC.Conference.Application/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandler.cs:17 · Level 11 · class

  • What it is: the handler that turns GetPublicSpeakerFilterQuery into a Speaker.Id IN (...) specification implementing BR-239: a speaker is publicly visible when they have at least one publicly visible session in the scoped published-event set.
  • Depends on: IQueryHandler<in TQuery, TResult>, IUnitOfWork, PublicConferenceVisibility, Specification<TEntity, TIdentifierType> and InlineSpecification<TEntity, TIdentifierType>, Speaker, and Result.
  • Concept reinforced: the filter-returning query handler introduced by GetPublicSpeakerCategoryItemFilterHandler, here with the optional event scope threaded through. It is worth understanding why the rule has to be resolved into ids at all: Speaker carries no status column and no event column, so "is this speaker public" is not a property of the speaker row. It is a fact about the sessions the speaker is linked to, which live in another aggregate. Resolving it to an id list is the BR-132 precedent, and the shape is shared with GetSpeakersByEventFilterHandler. [Rubric §4, DDD] assesses aggregate boundaries: Speaker keeps a by-id relationship to Session and Event instead of growing a navigation that would merge three aggregates into one query. [Rubric §8, Data Architecture]: an id-list criteria has no join, so it stays translatable on every engine the framework supports.
  • Walkthrough: the primary constructor (:17-19) injects IUnitOfWork. HandleAsync (:22-24) passes query.EventId straight through to PublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, query.EventId, cancellationToken) (:26-28), then returns Result.Success over an InlineSpecification<TEntity, TIdentifierType> with the criteria s => speakerIds.Contains(s.Id) (:30-31). All of the actual rule lives in PublicConferenceVisibility (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:104-134), which resolves the published-event set (:109), narrows it to the scoped event when one is supplied (an unpublished or unknown scoped event yields an empty list, :113-120), collects the BR-49-eligible session ids (:122-124), and projects the distinct speaker ids off the SessionSpeaker junction (:126-133).
  • Why it's built this way: the handler is deliberately thin. Keeping the rule in PublicConferenceVisibility is what lets the speaker filter, the junction filter, and the session filters share one definition of "published" and one definition of the BR-49 allow-list (expressed once as PublicSessionStatusSpecification and ANDed with the event-id scope, PublicConferenceVisibility.cs:148-149), so they cannot drift apart into three subtly different notions of public. The scope is passed through rather than resolved here because narrowing is a caller concern: only the paged list has an event context.
  • Testing: GetPublicSpeakerFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandlerTests.cs:22) is the largest of the filter suites, because the rule has the most edges: an accepted session publishes its speaker (:153), a null-status session does too (:164), while non-accepted-only (:193), EventSpeaker-only (:214), and orphan (:235) speakers stay hidden. The scope modes get their own tests: no scope spans every published event (:256), a scope narrows to that event (:267), an unpublished scope matches nothing (:279), and a scope with no eligible session matches nothing (:298).
  • Caveats / not-in-source: PublicConferenceVisibility's remarks (:97-103) record that the EventSpeaker join is deliberately not treated as a visibility grant, because the Sessionize import (SpeakerSyncStrategy) writes a row there for every speaker in the response, which once made this filter vacuous by publishing the entire imported roster. The session link is the only acceptance signal consulted.
  • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:58) and invoked from BuildPublicSpeakerSpecificationAsync (:84-95), which returns null for privileged readers so Organizers and ContentEditors see every speaker (:65,88-89). The controller's GetReadSpecificationAsync override calls that helper with eventId: null (:104-106), which is the rule spanning every published event, and it backs the unpaged list (:108), the lookup (:207,214), and GetById (:242,256). The paged action is the one caller that supplies a scope, resolving its own specification with the event id (:161) and ANDing it with the caller's own filter when there is one (:163-173).

GetSessionBookmarkCountQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetSessionBookmarkCount · MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionBookmarkCount/GetSessionBookmarkCountQuery.cs:6 · Level 0 · record

  • What it is: the query behind the speaker dashboard's "how many people bookmarked my talk" number (BR-210, GetSessionBookmarkCountQuery.cs:3). It names both the session being counted and the speaker asking, so the handler can authorize the read.
  • Depends on: the module identifier aliases SpeakerIdentifierType (a System.Guid) and SessionIdentifierType (an int), declared in MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19 and :15. Nothing else.
  • Concept introduced: carrying the subject alongside the object. The count itself only needs a session id. The speaker id is in the message because the authorization rule is "you may see the count for a session you are assigned to", and that rule is enforced by the handler rather than by a route filter. Putting the speaker in the query keeps the authorization input explicit and testable instead of hidden in ambient request state. [Rubric §11, Security] assesses whether object-level authorization is enforced next to the data access; here the pairing in the query is what makes that possible. [Rubric §6, CQRS & Event-Driven]: a read that spans two bounded contexts still travels as one ordinary query record with no behavior on it.
  • Walkthrough: public sealed record GetSessionBookmarkCountQuery(SpeakerIdentifierType SpeakerId, SessionIdentifierType SessionId) (:6), with per-parameter docs naming SpeakerId as "the speaker requesting the count" (:4) and SessionId as the session to count (:5). There are no members, no markers, and no cache-invalidation interface.
  • Where it's used: constructed by SpeakersController at MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:449 for the GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count endpoint (:439-454), and answered by GetSessionBookmarkCountHandler. Its batch sibling is GetSessionBookmarkCountsQuery, which the dashboard uses to avoid a per-session fan-out (:459-470).

GetSessionBookmarkCountsQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetSessionBookmarkCounts · MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionBookmarkCounts/GetSessionBookmarkCountsQuery.cs:6 · Level 0 · record

  • What it is: the read intent behind the Speaker Dashboard's bookmark widget. It asks, in one call, "how many people bookmarked each of these sessions?", carrying the requesting speaker plus the set of session ids to count (BR-210, GetSessionBookmarkCountsQuery.cs:3).
  • Depends on: the identifier aliases SpeakerIdentifierType and SessionIdentifierType (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19,15), used at :7-8. The only external is BCL IReadOnlyCollection<T>.
  • Concept introduced: the batched query, and why the caller's id list is an input rather than an authorization. The singular sibling GetSessionBookmarkCountQuery answers for one session; this record takes a whole collection (:8) so a dashboard listing N sessions makes one round trip instead of N. The important design point is what the record does not mean: SessionIds is a request, not a grant. The speaker id travels alongside (:7) precisely so GetSessionBookmarkCountsHandler can re-derive server-side which of those sessions the speaker is actually entitled to see. [Rubric §11, Security] assesses whether authorization decisions are made from server-held state rather than from client-supplied lists: the shape of this query is what makes that possible, because it forces the pairing of "who is asking" with "what they asked about". [Rubric §12, Performance & Scalability]: collapsing a per-row fan-out into one batched intent is the query-shape half of an N+1 fix.
  • Walkthrough: two positional parameters on a sealed record (:6-8), SpeakerId (:7) and SessionIds (:8). The declared parameter type is IReadOnlyCollection<SessionIdentifierType>, so the handler can cheaply test Count before doing any work without committing the caller to a particular collection implementation. There are no methods and no markers: this is a pure read intent.
  • Why it's built this way: queries in this codebase are plain records with no behavior, so the IQueryHandler<in TQuery, TResult> implementation stays the single place where the read is described (see Group 05).
  • Where it's used: constructed by SpeakersController on GET {speakerId}/sessions/bookmarks/counts (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:460-471), which binds sessionIds from the query string with [FromQuery] (:464) and null-coalesces a missing array to an empty one (:468).

GetSessionFeedbackQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetSessionFeedback · MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionFeedback/GetSessionFeedbackQuery.cs:6 · Level 0 · record

  • What it is: the read intent for a speaker's post-session feedback report: the aggregated ratings and free-text responses for one session (BR-210, GetSessionFeedbackQuery.cs:3).
  • Depends on: the identifier aliases SpeakerIdentifierType and SessionIdentifierType (:6). Nothing else.
  • Concept reinforced: the same "who is asking plus what they asked about" pair taught at GetSessionBookmarkCountsQuery, in its single-target form. The speaker id is not decorative: GetSessionFeedbackHandler rejects the read with a Forbidden error when the speaker is not assigned to the session, so the query type carries exactly the two facts the authorization check needs. [Rubric §11, Security]: the read is scoped by a server-verified relationship, not by trusting the route.
  • Walkthrough: a one-line sealed record with two positional parameters, SpeakerId and SessionId (:6). The XML docs (:3-5) name the business rule and each parameter's role.
  • Where it's used: constructed by SpeakersController on GET {speakerId}/sessions/{sessionId}/feedback (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:417-436). The endpoint is [Authorize] (:417) and applies a self-or-organizer gate before the handler ever runs: the caller must hold the Organizer role or carry the route speaker's speaker_id claim, otherwise it returns Forbid() (:423-426). It carries no [OutputCache] attribute, and the endpoint summary records why (:410-415): free-text comments are the speaker's own read, so every response is authorization-dependent and must not be publicly cached.

GetSpeakersByEventFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetSpeakersByEventFilter · MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterQuery.cs:12 · Level 0 · record

  • What it is: the intent "give me a filter that selects the speakers belonging to this event". It carries one field, the EventId (:12), and its handler returns a Specification<TEntity, TIdentifierType> rather than data.
  • Depends on: the EventIdentifierType alias (an int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8), used at :12. Nothing else.
  • Concept introduced: the query that returns a specification, not rows. Most queries in this module resolve to a DTO or a count. This one resolves to a predicate object that the caller then composes into a larger read. The reason is spelled out in the type's own doc comment (:3-10): a Speaker has no EventId column, and it can belong to an event by two independent link paths, the EventSpeaker join written by the Sessionize sync and the SessionSpeaker join written by organizer session management. Resolving that union has to happen in a handler with repository access, but the result still has to be a filter so it can be ANDed with the caller's other criteria and passed to the generic paged read. Returning a specification is how a multi-step lookup is turned back into a single composable clause. [Rubric §2, Design Patterns] assesses whether recognized patterns are applied where they earn their keep: this is Specification used as a first-class return value, not just as a parameter. [Rubric §8, Data Architecture]: the doc comment records the deliberate choice to resolve the joins as ID-list projections so the criteria stay engine-portable rather than depending on a navigation join.
  • Walkthrough: the whole type is one line (:12); the nine lines above it (:3-11) are the design rationale, which is unusually long for a record and is the load-bearing part to read. It names the two link paths, states that they are populated by different flows so the handler must union them, and notes that Speaker has no EventId column.
  • Why it's built this way: keeping Speaker free of an EventId column preserves the aggregate boundary (a speaker exists independently of any event, and relates to events by id, not by containment). The cost of that DDD choice is this two-path lookup, and the specification return type is what keeps the cost contained in one handler. See ADR-055 for the repository-plus-specification data-access contract this leans on.
  • Where it's used: SpeakersController lifts an EventId out of the incoming filter dictionary and removes it unconditionally, because Speaker has no such column and the generic filter pipeline rejects unknown properties (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:150-160). When an id parsed, it calls the handler and folds the returned specification into the public-visibility specification (:161-175) before running the paged read (:177-188).
  • Caveats / not-in-source: this filter is distinct from the BR-239 public-visibility rule, which is resolved separately by BuildPublicSpeakerSpecificationAsync (SpeakersController.cs:161). The two are ANDed (:171-173), so an event-scoped listing shows the intersection, not the union.

ISpeakerFieldsRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/ISpeakerFieldsRequest.cs:12 · Level 0 · interface

  • What it is: the six speaker fields that the create request and the update request validate identically, named as one interface so the shared rule list can be declared once instead of twice.
  • Depends on: nothing. Six read-only string properties and no base interface (:12-31).
  • Concept introduced: the request-shape interface as a validation contract. Every other validation fragment in this module is generic in T and takes a property selector, because a fragment cannot know where its field lives on a request. That works, but it pushes the selector list into every concrete validator, so the create and update validators drift the moment someone edits one and not the other. Declaring the shared shape as an interface flips the dependency: SpeakerFieldRules<T> constrains where T : ISpeakerFieldsRequest (SpeakerValidationRules.cs:115) and writes the six selectors itself (:119-124), so a concrete validator is reduced to one Include. The interface is deliberately narrower than either request record: the create request's FullName stays off it because only the create path carries it and no validator has a rule for it, a decision the <remarks> block records in the type itself (:8-11). That is interface segregation applied to a DTO shape. [Rubric §1, SOLID] assesses whether abstractions carry only what their clients use: this one carries exactly the fields with rules attached and nothing else. [Rubric §15, Best Practices & Code Quality]: adding a seventh shared field means adding it here plus one Include in SpeakerFieldRules<T>, and both request paths pick it up. [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are declared once and applied consistently at every entry path, which is precisely the property this interface buys.
  • Walkthrough: FirstName and LastName are non-nullable string (:15,18); Email, LinkedInUrl, GitHubUrl and WebsiteUrl are string? (:21,24,27,30). All six are get-only, so the interface commits an implementer to exposing the value and nothing about how it is set. Note the nullability mismatch this creates downstream: SpeakerEmailRules<T> takes an Expression<Func<T, string>> (SpeakerValidationRules.cs:41) while Email here is string?, so SpeakerFieldRules<T> passes p => p.Email! with the null-forgiving operator (:121). It is safe because the rule body only runs inside a When clause that has already rejected null or whitespace (:45-46), but the ! is where that reasoning is encoded rather than expressed in the type.
  • Where it's used: implemented by SpeakerCreateRequest (MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequest.cs:11, alongside ICreateRequest and ICacheInvalidating) and by SpeakerUpdateRequest (MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequest.cs:8), and consumed as a generic constraint by SpeakerFieldRules<T>.
  • Caveats / not-in-source: both request records carry Bio, TagLine, ProfilePicture and TwitterHandle (SpeakerCreateRequest.cs:32,35,38,44; SpeakerUpdateRequest.cs:20,23,26,32) that are not on this interface and have no rule fragment anywhere in the speaker validation file. SpeakerInvariants declares max lengths for all four (MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:25,28,31, plus SpeakerDTO.BioMaxLength at MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:37), and the aggregate's own guards cover only first name, last name and answer value (SpeakerInvariants.cs:45-58), so for those four fields the only enforced bound is whatever the EF column mapping applies.

SpeakerEmailRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:38 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment for a speaker's optional email address. When a value is supplied it must be a well-formed address of at most 255 characters; when it is absent nothing is checked.
  • Depends on: FluentValidation's AbstractValidator<T> (extended directly, SpeakerValidationRules.cs:39), the framework's EmailRules<T> (:46), SpeakerInvariants for the bound (:3,46), and System.Linq.Expressions.Expression<Func<T, string>> for the property selector (:1,41).
  • Concept introduced: making a required rule optional with When, and the cost of the compiled accessor. EmailRules<T> is a required email rule: it chains NotEmpty, EmailAddress and MaximumLength in one pass (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:67-70). A speaker's email is optional, and the naive fix would be to re-declare the two clauses that still apply and drop NotEmpty. This fragment does the opposite: it keeps the framework rule intact and gates the entire Include behind a When predicate (:45-46), so the definition of "a valid email" stays in exactly one place for the whole workspace and this file only decides whether to apply it. The mechanism has a price worth understanding: the When predicate needs a value, not an expression tree, so the constructor calls selector.Compile() (:43) and closes over the resulting delegate. Compilation happens once per fragment instance (that is, once per validator construction), and the delegate is then invoked on each validation pass. [Rubric §24, Forms/Validation/UX Safety] assesses whether validation matches the real contract of the field: an optional field validated by a required rule would reject legitimate blank input, and this is how that is avoided without weakening the format check. [Rubric §15, Best Practices & Code Quality]: reuse over re-declaration, so the format rule cannot drift per module.
  • Walkthrough: sealed class SpeakerEmailRules<T> : AbstractValidator<T> (:38-39); the constructor takes the selector (:41), compiles it into an accessor (:43), and registers one conditional group: When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new EmailRules<T>(selector, "Email", SpeakerInvariants.EmailMaxLength))) (:45-46). The label "Email" is interpolated into the generated messages ("You must enter a Email", "You must enter a valid Email", CommonValidationRules.cs:68-70); EmailMaxLength is 255 (MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:27, surfaced through SpeakerInvariants.cs:22). No error code is supplied, so the failures carry FluentValidation's default codes rather than a stable Speaker.Email.* code.
  • Why it's built this way: the class doc (:30-36) states the business reason for optionality directly: a speaker imported from Sessionize can arrive without an email, and the BR-207 auto-link by email match simply finds no user in that case. A supplied value must still be a real address, because it is the one the organizer contacts the speaker on. Rejecting the import to satisfy a validator would be the wrong trade.
  • Where it's used: Included by SpeakerFieldRules<T> (:121), which passes p => p.Email! because ISpeakerFieldsRequest declares the property nullable.

SpeakerFirstNameRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:12 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment for a speaker's first name: required and bounded to 200 characters. It is the first type declared in the speaker validation file.
  • Depends on: RequiredStringRules<T> (its base class, SpeakerValidationRules.cs:13), SpeakerInvariants for the bound (:3,16), and Expression<Func<T, string>> for the selector (:1,15).
  • Concept reinforced: the parameterized rule fragment introduced at SessionEventIdRules<T>, here in its simplest form. Deriving from the framework's required base rather than the optional one is how a fragment declares required-versus-optional: RequiredStringRules<T> chains NotEmpty() then MaximumLength(maxLength) with generated messages built from the field label (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:44-46). [Rubric §1, SOLID]: one fragment, one field contract, composed rather than copied.
  • Walkthrough: sealed class SpeakerFirstNameRules<T> : RequiredStringRules<T> (:12-13); the constructor (:15) forwards base(selector, "First Name", SpeakerInvariants.FirstNameMaxLength) (:16). FirstNameMaxLength is 200 (MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:21, re-exported by SpeakerInvariants.cs:16), the same constant the aggregate's own guard cites when it builds the Speaker.FirstName.TooLong failure (MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:48).
  • Why it's built this way: sourcing the length from SpeakerInvariants rather than a literal keeps the request validator, the domain guard, and the EF column width citing one constant, so a limit change cannot drift between layers.
  • Testing: SpeakerValidationRulesTests covers this fragment through a happy path (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerValidationRulesTests.cs:25), an empty value (:32), and an over-length value (:40).
  • Where it's used: Included by SpeakerFieldRules<T> with the selector p => p.FirstName (:119).
  • Caveats / not-in-source: neither this fragment nor any other in the file supplies the optional errorCode argument the framework bases accept (CommonValidationRules.cs:43,55,66,87), so request-level failures surface with FluentValidation's default codes, not the stable Speaker.<Field>.<Reason> codes the domain invariants use (SpeakerInvariants.cs:47-53).

SpeakerGitHubUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:76 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment for a speaker's optional GitHub profile URL. When a value is supplied it must be at most 2000 characters and an absolute http or https URL.
  • Depends on: AbstractValidator<T> directly (:77), the framework's AbsoluteUrlRules<T> (:84), SpeakerInvariants (:84), and Expression<Func<T, string?>> for the selector (:79).
  • Concept introduced: the scheme check as an injection control at the write boundary. A length-only bound on a URL column accepts javascript: and data: values, which become executable the moment something renders them as a link href or an image src. AbsoluteUrlRules<T> closes that by chaining MaximumLength with Must(BeAnAbsoluteHttpUrl) (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:88-90), and it delegates the predicate to CommonInvariants.EnsureUrlIsWellFormed (:93) so the request validator and the domain invariant answer identically rather than drifting into two definitions of "valid URL". This fragment's own doc states the concrete exposure it is guarding (:69-74): the speaker pages put the stored value straight into a link. Note the deliberate contrast with the session URL fragments in this same module: SessionLiveUrlRules<T> checks length only, on purpose, because that value must round-trip whatever Sessionize wrote. Speaker profile links are organizer-entered, so the stronger rule is affordable here and is not there. [Rubric §11, Security] assesses whether untrusted input is constrained where it enters the system rather than only where it is displayed; [Rubric §26, Front-End Security] is the paired concern the check relieves, since the renderer no longer has to scheme-filter a value the writer already rejected. [Rubric §24, Forms/Validation/UX Safety]: the caller is told at submit time, not silently given a dead or dangerous link.
  • Walkthrough: sealed class SpeakerGitHubUrlRules<T> : AbstractValidator<T> (:76-77); the constructor compiles the selector into an accessor (:81) and gates the whole rule behind When(x => !string.IsNullOrWhiteSpace(accessor(x)), ...) (:83), exactly the optionality mechanism taught at SpeakerEmailRules<T>. Inside, it includes new AbsoluteUrlRules<T>(selector, "GitHub URL", SpeakerInvariants.GitHubUrlMaxLength) (:84); GitHubUrlMaxLength is 2000 (MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:49, via SpeakerInvariants.cs:37). The label "GitHub URL" is what appears in both generated messages, including "GitHub URL must be an absolute http or https URL" (CommonValidationRules.cs:90).
  • Where it's used: Included by SpeakerFieldRules<T> with the selector p => p.GitHubUrl (:123).
  • Caveats / not-in-source: the rule constrains the scheme and the length, not the host, so an absolute https URL pointing anywhere passes. Nothing in this fragment requires the value to be a github.com address.

SpeakerLastNameRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:23 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment for a speaker's last name: required and bounded to 200 characters.
  • Depends on: RequiredStringRules<T> (:24) and SpeakerInvariants (:27).
  • Concept reinforced: structurally identical to SpeakerFirstNameRules<T>; only the label and the constant differ.
  • Walkthrough: the constructor (:26) forwards base(selector, "Last Name", SpeakerInvariants.LastNameMaxLength) (:27). LastNameMaxLength is 200 (MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:24, via SpeakerInvariants.cs:19), the same constant the aggregate guard cites for Speaker.LastName.TooLong (MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:53).
  • Testing: SpeakerValidationRulesTests covers the empty case (SpeakerValidationRulesTests.cs:48) and the over-length case (:56).
  • Where it's used: Included by SpeakerFieldRules<T> with p => p.LastName (:120).

SpeakerLinkedInUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:57 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment for a speaker's optional LinkedIn profile URL: length-bounded to 2000 characters and required to be an absolute http or https URL when supplied.
  • Depends on: AbstractValidator<T> (:58), AbsoluteUrlRules<T> (:65), and SpeakerInvariants (:65).
  • Concept reinforced: byte-for-byte the same shape and rationale as SpeakerGitHubUrlRules<T>, including the identical <summary> text about keeping an executable javascript: or data: target out of a rendered link (:50-55).
  • Walkthrough: the constructor (:60) compiles the selector (:62), gates on a non-blank value (:64), and includes new AbsoluteUrlRules<T>(selector, "LinkedIn URL", SpeakerInvariants.LinkedInUrlMaxLength) (:65); LinkedInUrlMaxLength is 2000 (MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:46, via SpeakerInvariants.cs:34).
  • Where it's used: Included by SpeakerFieldRules<T> with p => p.LinkedInUrl (:122).

SpeakerWebsiteUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:95 · Level 7 · class (sealed, generic)

  • What it is: the rule fragment for a speaker's optional personal website URL, on the same terms as the two profile-link fragments.
  • Depends on: AbstractValidator<T> (:96), AbsoluteUrlRules<T> (:103), and SpeakerInvariants (:103).
  • Concept reinforced: identical to SpeakerGitHubUrlRules<T>. This is the field where the scheme check matters most, because a personal website is arbitrary user-supplied text with no expected host at all.
  • Walkthrough: the constructor (:98) compiles the selector (:100), gates on a non-blank value (:102), and includes new AbsoluteUrlRules<T>(selector, "Website URL", SpeakerInvariants.WebsiteUrlMaxLength) (:103); WebsiteUrlMaxLength is 2000 (MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:52, via SpeakerInvariants.cs:40).
  • Where it's used: Included by SpeakerFieldRules<T> with p => p.WebsiteUrl (:124).

SpeakerFieldRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.Validation · MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:113 · Level 8 · class (sealed, generic)

  • What it is: the one composite that folds all six speaker field fragments into a single includable validator, bound to any request type that implements ISpeakerFieldsRequest.
  • Depends on: AbstractValidator<T> (:114), the generic constraint ISpeakerFieldsRequest (:115), and the six fragments SpeakerFirstNameRules<T>, SpeakerLastNameRules<T>, SpeakerEmailRules<T>, SpeakerLinkedInUrlRules<T>, SpeakerGitHubUrlRules<T> and SpeakerWebsiteUrlRules<T> (:119-124).
  • Concept introduced: the second level of validator composition. The rest of this module composes fragments per validator: a create validator lists its Include calls, an update validator lists its own, and keeping the two in step is a review job. This type inserts one more layer. Because the constraint at :115 guarantees T exposes the six properties, the composite can write the selectors itself (p => p.FirstName, and so on) instead of accepting them, which is what collapses each concrete validator down to a single line. [Rubric §1, SOLID] assesses dependency direction and single responsibility: the composite owns "what the shared speaker fields are", each fragment owns "what one field's rule is", and the concrete validators own only their per-operation delta. [Rubric §15, Best Practices & Code Quality]: a new shared field is one property on the interface plus one Include here, and both entry paths inherit it with no edit. [Rubric §24, Forms/Validation/UX Safety]: create and update cannot diverge on a shared field, because there is exactly one declaration of the shared set.
  • Walkthrough: sealed class SpeakerFieldRules<T> : AbstractValidator<T> where T : ISpeakerFieldsRequest (:113-115). The parameterless constructor (:117) issues six Include calls in field order (:119-124). Two details are worth noticing. First, Include on FluentValidation flattens the child validator's rules into this one, so the six fragments produce one flat failure list rather than nested results. Second, the email selector is written p => p.Email! (:121) because SpeakerEmailRules<T> takes a non-nullable selector while the interface property is string?; the fragment's own When guard is what makes the suppression safe.
  • Why it's built this way: the class doc (:107-111) states the intent as "each concrete request validator includes this and adds only the rules its own operation needs".
  • Where it's used: SpeakerCreateRequestValidator (MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:10) and SpeakerUpdateRequestValidator (MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:10). Both are expression-bodied constructors containing exactly one Include of this type.
  • Caveats / not-in-source: the per-operation delta the doc anticipates is empty today: neither concrete validator adds a single rule of its own, so the two validators are currently distinguishable only by their type argument. That includes SpeakerCreateRequest.FullName (SpeakerCreateRequest.cs:26), which is required on the record but has no validation rule on any path.

GetSessionBookmarkCountHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetSessionBookmarkCount · MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionBookmarkCount/GetSessionBookmarkCountHandler.cs:14 · Level 9 · class

  • What it is: the handler for GetSessionBookmarkCountQuery. It verifies the asking speaker is actually assigned to the session, then asks the Engagement module for the count. Conference never reads Engagement's bookmark table itself.
  • Depends on: IQueryHandler<in TQuery, TResult> (GetSessionBookmarkCountHandler.cs:14,16), IUnitOfWork (:3,15), IBookmarkCountService from MMCA.ADC.Engagement.Shared.UserSessionBookmarks (:2,16), Session with its SessionSpeaker children (:1,26), plus Result and Error (:5).
  • Concept introduced: the cross-context read through an owned interface. Bookmarks belong to Engagement's bounded context and live in Engagement's own database (database-per-service, ADR-006), so there is no join available and no table Conference is allowed to touch. Instead Engagement publishes a two-method contract in its Shared project, marked [ServiceContract] to declare it part of an extracted service's wire surface (MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:10-11), and Conference depends on that abstraction (GetSessionBookmarkCountHandler.cs:16). In the monolith DI binds it to the in-process BookmarkCountService with TryAddScoped (MMCA.ADC.Engagement.Application/DependencyInjection.cs:46); in the extracted topology the Conference service host calls AddEngagementBookmarkCountClient() (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350), which registers the typed gRPC client and then uses services.Replace so the adapter wins over whatever is already in the container (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:43-51, adapter at MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/BookmarkCountServiceGrpcAdapter.cs:14); and when the Engagement module is switched off entirely the module's RegisterDisabledStubs hook supplies DisabledBookmarkCountService (MMCA.ADC.Engagement.API/EngagementModule.cs:30-32). The handler is unchanged in all three cases. [Rubric §7, Microservices Readiness] assesses whether cross-module calls go through abstractions that can be re-pointed at a transport; this is that pattern in one file, and the gRPC path is ADR-007. [Rubric §3, Clean Architecture]: the Application layer names an interface and never a transport. [Rubric §11, Security]: the ownership check sits in the handler, immediately beside the data it guards.
  • Walkthrough: the primary constructor (:14-16) injects IUnitOfWork and IBookmarkCountService. HandleAsync (:19-21) resolves the session repository off the unit of work (:23, never by constructor-injecting IRepository<,> directly) and loads the session by id with its SessionSpeakers included and asTracking: false (:24-28), since this is a pure read. A missing session returns Error.NotFound stamped with the handler name and target (:29-30). The authorization step (:33) then requires at least one non-soft-deleted SessionSpeaker whose SpeakerId matches the caller, and otherwise returns Error.Forbidden coded Speaker.NotAssigned with a message, source and target (:35-40). Only after both checks does it call bookmarkCountService.GetBookmarkCountForSessionAsync(query.SessionId, cancellationToken) (:43) and wrap the integer in Result.Success (:45).
  • Why it's built this way: ordering matters. The not-found check precedes the assignment check, and the assignment check precedes the cross-context call, so an unauthorized caller never causes a gRPC hop and never learns anything beyond "forbidden". The explicit !ss.IsDeleted test at :33 is written out because the child collection is filtered in memory after eager loading rather than by the EF global query filter alone (ADR-005).
  • Testing: GetSessionBookmarkCountHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetSessionBookmarkCount/GetSessionBookmarkCountHandlerTests.cs:11) covers exactly the three outcomes above: a missing session (:25), an unassigned speaker (:45), and the assigned happy path returning the count (:66).
  • Where it's used: injected into SpeakersController as IQueryHandler<GetSessionBookmarkCountQuery, Result<int>> (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:55) and invoked by the GET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/count endpoint (:439-454), which is [AllowAnonymous] (:440) and served through the BookmarkCountsCache output-cache policy (:441). The batch equivalent used by the speaker dashboard is GetSessionBookmarkCountsHandler (:459-470).

GetSessionBookmarkCountsHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetSessionBookmarkCounts · MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionBookmarkCounts/GetSessionBookmarkCountsHandler.cs:17 · Level 9 · class

  • What it is: the handler for GetSessionBookmarkCountsQuery. It re-verifies which of the requested sessions actually belong to the asking speaker, then asks the Engagement module for the bookmark counts of just those sessions, in one batched call.
  • Depends on: IQueryHandler<in TQuery, TResult> (GetSessionBookmarkCountsHandler.cs:17,20), IUnitOfWork (:3,18), IBookmarkCountService (:2,19), Session with its SessionSpeakers navigation (:1,35), and Result (:5).
  • Concept introduced: filtering-not-failing authorization on a batch. The cross-context mechanics are the same as the singular sibling (GetSessionBookmarkCountHandler teaches them), so what is new here is the authorization posture (:40-46): the handler keeps only sessions the speaker is assigned to and silently drops the rest rather than failing the whole batch, so one stale or foreign id in a dashboard's list never denies the speaker the counts they are entitled to. Contrast that deliberately with the single-target reads, which return Forbidden on a mismatch: a batch read filters, a single read fails. [Rubric §11, Security]: the client's id list is treated as a request, never as a grant, and the class doc comment states that intent explicitly (:11-15). [Rubric §7, Microservices Readiness]: the only Engagement-shaped thing this handler knows is one interface, so the module can be extracted without touching it (ADR-007).
  • Walkthrough: the primary constructor takes the unit of work and the count service (:17-19); the class implements IQueryHandler<GetSessionBookmarkCountsQuery, Result<IReadOnlyDictionary<SessionIdentifierType, int>>> (:20). HandleAsync (:23-25) starts with an empty-input short circuit returning an empty dictionary without touching the database (:27-31). It then takes the read repository (GetReadRepository, :33) and loads the requested sessions with their SessionSpeakers eager-included, asTracking: false (:34-38). The authorization projection (:43-46) keeps sessions where any SessionSpeaker matches the query's SpeakerId and is not soft-deleted, and selects just the ids. A second short circuit returns an empty dictionary when nothing survived (:48-52). Finally it calls bookmarkCountService.GetBookmarkCountsForSessionsAsync(authorizedSessionIds, ...) (:54-56) and wraps the returned dictionary in Result.Success (:58).
  • Why it's built this way: the batched contract exists so the Speaker Dashboard makes one call instead of one per session; the class doc comment (:9-16) names that as the reason, and the interface contract guarantees every requested id is present in the result with zero-bookmark sessions mapping to 0 (MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:22-23). Note the explicit !ss.IsDeleted test at :44: the eager-loaded child collection is filtered in memory here, so the check is written out rather than relying solely on the EF global soft-delete filter (ADR-005). [Rubric §12, Performance & Scalability]: two round trips total (one local read, one cross-module call) regardless of session count, and zero when the id list is empty.
  • Testing: GetSessionBookmarkCountsHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetSessionBookmarkCounts/GetSessionBookmarkCountsHandlerTests.cs:11) pins all three paths: the empty-input short circuit asserted to query nothing (:34), the filtering behavior where only the speaker's own sessions are counted (:48), and the "no requested session is assigned" case that returns empty without counting (:73).
  • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:56) and invoked from the bookmarks/counts endpoint (:467-469), which is [AllowAnonymous] and served through the BookmarkCountsCache output-cache policy (:460-461).
  • Caveats / not-in-source: when the Engagement module is disabled in a host, the interface resolves to DisabledBookmarkCountService instead (MMCA.ADC.Engagement.API/EngagementModule.cs:32), whose batch method returns a zero for each distinct requested id rather than an empty map (MMCA.ADC.Engagement.Shared/UserSessionBookmarks/DisabledBookmarkCountService.cs:18-22), so this handler's cross-module hop degrades to "all zeros" rather than failing.

GetSessionFeedbackHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetSessionFeedback · MMCA.ADC.Conference.Application/Speakers/UseCases/GetSessionFeedback/GetSessionFeedbackHandler.cs:15 · Level 9 · class

  • What it is: the handler for GetSessionFeedbackQuery. It confirms the speaker is assigned to the session, then aggregates that session's answers into average ratings per rating question and raw text lists per open question.
  • Depends on: IQueryHandler<in TQuery, TResult> (GetSessionFeedbackHandler.cs:15,16), IUnitOfWork (:5,16), Session and Question (:2-3), SessionFeedbackDTO with RatingQuestionSummary and TextQuestionResponses (:4; all three declared in MMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:6,22,38), Result and Error (:7), and BCL System.Globalization for culture-invariant parsing (:1).
  • Concept introduced: the in-context analytics handler, and defensive parsing of a stringly-typed answer store. Unlike its bookmark sibling, this handler needs no cross-module call: session questions and answers are Conference-owned, so everything is local. Two mechanisms are worth learning here. The first is the ownership gate (:33-40): if no live SessionSpeaker matches the query's speaker, the handler returns Error.Forbidden with the stable code Speaker.NotAssigned plus a message, source and target, so a speaker cannot read another speaker's feedback by editing the URL. Note the deliberate distinction from GetSessionBookmarkCountsHandler: a single-target read fails on a mismatch, a batch read filters. The second is the answer model: SessionQuestionAnswer stores AnswerValue as a string regardless of question type, so a Rating answer must be parsed back to an integer. The handler uses int.TryParse with NumberStyles.Integer and CultureInfo.InvariantCulture (:76) and drops values that do not parse, rather than throwing. Culture-invariance is the load-bearing detail: parsing a stored value with the ambient culture makes the same database return different results on different servers. [Rubric §11, Security] (server-verified ownership), [Rubric §15, Best Practices & Code Quality] (invariant-culture parsing, no exceptions used for flow control), [Rubric §12, Performance & Scalability] (two queries maximum, with the second skipped entirely when there are no answers).
  • Walkthrough: the primary constructor takes only the unit of work (:15-16). HandleAsync (:19-21) takes the Session repository (:23) and loads the session by id with SessionSpeakers and SessionQuestionAnswers eager-included and asTracking: false (:24-28), returning Error.NotFound sourced and targeted for diagnostics when it is missing (:29-30). The ownership gate follows (:33-40). With no answers it returns an empty SessionFeedbackDTO carrying just the session id and title (:44-53), avoiding the question query altogether. Otherwise it collects the distinct answered question ids into a HashSet (:56) and loads only those questions (:57-62), building a dictionary lookup (:63). The aggregation loop groups answers by question id (:68), skips a group whose question was not found (:70-71), and branches on question.QuestionType == "Rating" (:73): the rating branch parses each answer, keeps the parsed values (:75-79), and, only if at least one parsed (:81), emits a RatingQuestionSummary with AverageRating and ResponseCount (:83-89); every other question type emits a TextQuestionResponses with all raw answer strings via the collection expression [.. group.Select(a => a.AnswerValue)] (:94-99). The final DTO is assembled and wrapped in Result.Success (:103-109).
  • Why it's built this way: the comment at :42 records that soft-deleted answers are already excluded by the EF global query filter, so the aggregation does not re-filter them (contrast the explicit !ss.IsDeleted on the eager-loaded speaker links at :33). Loading questions by the answered-id set rather than by session avoids pulling the whole question bank. See ADR-005 for the soft-delete model these filters implement.
  • Testing: GetSessionFeedbackHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetSessionFeedback/GetSessionFeedbackHandlerTests.cs:11) covers the missing session (:27), the unassigned speaker (:48), and the no-answers short circuit (:69).
  • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:54) and invoked from the session-feedback endpoint after its self-or-organizer gate (:428-430).
  • Caveats / not-in-source: the rating branch is selected by the literal string "Rating" (:73); the domain does constrain the column to ["Rating", "Text", "Email"] (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:34), but this handler shares no constant with it, so the two definitions are coupled only by convention. Both repository calls use GetRepository rather than GetReadRepository (:23,57), though both pass asTracking: false, so the reads are untracked either way.

GetSpeakersByEventFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.GetSpeakersByEventFilter · MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterHandler.cs:19 · Level 9 · class

  • What it is: the handler for GetSpeakersByEventFilterQuery. It resolves the two independent speaker-to-event link paths into one id list and returns a specification that filters Speaker by that list.
  • Depends on: IQueryHandler<in TQuery, TResult> (GetSpeakersByEventFilterHandler.cs:19,21), IUnitOfWork (:4,20), Specification<TEntity, TIdentifierType> and InlineSpecification<TEntity, TIdentifierType> (:6,53), the join entities EventSpeaker and SessionSpeaker plus Session (:1-3), and Result (:7).
  • Concept introduced: projection queries and the ID-list filter. The handler never materializes an entity. It uses GetProjectedAsync on the read repository up to three times, each pulling a single scalar column with a where clause and asTracking: false: speaker ids from the event-speaker join (:28-31), session ids for the event (:33-36), and speaker ids from the session-speaker join for those sessions (:44-47). Projecting rather than loading is what keeps a potentially wide join cheap: the query returns ids, not aggregates. The result is then expressed as an InlineSpecification<TEntity, TIdentifierType> over speakerIds.Contains(s.Id) (:52-53), which EF translates to a SQL IN. That indirection is deliberate: because the predicate closes over an in-memory list rather than over a navigation property, the criteria stay translatable on any provider, which is the engine-portability point the class doc comment makes (:15-17; see ADR-055 and the multi-engine motivation in ADR-018). [Rubric §8, Data Architecture] assesses whether queries stay portable and index-friendly; [Rubric §4, Domain-Driven Design]: Speaker relates to events by id across an aggregate boundary, never by owning an EventId column.
  • Walkthrough: the primary constructor takes only the unit of work (:19-20); the handler's TResult is Result<Specification<Speaker, SpeakerIdentifierType>> (:21). HandleAsync (:24-26) runs the direct-link projection first (:28-31), then the event's session ids (:33-36). sessionSpeakerIds is initialized to an empty collection (:38) and the third query runs only when there is at least one session (:39), so an event with no sessions costs two queries, not three. Inside the branch, the session ids are materialized once into an IReadOnlyList<T> (:42) with an explanatory comment (:41): the predicate must close over a stable collection for EF to translate it into IN rather than re-enumerating a deferred sequence. The two id sets are then concatenated and de-duplicated into one list (:50), and the specification is constructed and returned as a success (:52-53). The handler never returns a failure.
  • Why it's built this way: the union is necessary because the two link paths are written by different flows (the Sessionize import writes EventSpeaker, organizer session management writes SessionSpeaker), a fact recorded in the query's own doc comment (MMCA.ADC.Conference.Application/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterQuery.cs:5-8). Returning a specification instead of speaker rows lets the caller AND this filter with its own visibility rules and still use the shared paged read path. The class doc comment cites the BR-132 cross-source specification helper as the precedent for the shape (:15).
  • Testing: GetSpeakersByEventFilterHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/GetSpeakersByEventFilter/GetSpeakersByEventFilterHandlerTests.cs:18) exercises the returned criteria directly rather than the return value alone: a directly linked speaker matches (:94), a transitive session speaker matches (:104), both paths are unioned (:114), an event with no links matches nothing (:129), an event with no sessions skips the third projection (:137), and the cancellation token reaches every projection (:154).
  • Where it's used: injected into SpeakersController (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:57); on a successful result the controller composes the returned specification with the public-visibility specification through the And extension, which builds an AndSpecification<TEntity, TIdentifierType> (SpeakersController.cs:169-174; MMCA.Common/Source/Core/MMCA.Common.Domain/Specifications/SpecificationExtensions.cs:48-53), and passes the combination to the paged query service (:177-188).
  • Caveats / not-in-source: the returned specification embeds a materialized id list, so its size grows with the event's speaker count. No cap is applied in this handler. Note also that a failed result at the call site is swallowed: the controller only composes when filterResult.IsSuccess (SpeakersController.cs:169), so a failure would silently fall back to the public specification alone. This handler has no failure path today, so that branch is unreachable from that call site.

GetPublicSponsorFilterQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.GetPublicSponsorFilter · MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterQuery.cs:13 · Level 0 · record

  • What it is: the parameterless query that asks for the read filter applied to sponsors. A sponsor is publicly visible when the event it was sold against is published (BR-108), so the query carries no input at all: the rule is derived entirely from server-held state.
  • Depends on: nothing first-party. It is a bare public sealed record with an empty parameter list and no members (MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterQuery.cs:13).
  • Concept introduced: none new; this is the filter-query-as-lookup-token shape used by the sibling read filters, GetPublicSessionFilterQuery and GetPublicSpeakerFilterQuery. What is worth reading here is the <remarks> block (:8-12), which records the one structural difference from those siblings: Sponsor carries a real EventId column (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45), so the rule resolves to a published-event id list and returns as a Sponsor.EventId IN (...) criteria with no navigation join anywhere in the expression tree. [Rubric §11, Security] assesses where visibility is decided: the query has no field an anonymous caller could set, so the rule cannot be widened from the wire, and the summary states the leak being prevented, an event still being assembled exposing its sponsor roster before announcement (:4-7). [Rubric §8, Data Architecture] assesses query portability: keeping the criteria to a scalar IN is what makes it translatable on any engine (ADR-018, named in the remarks at :11).
  • Walkthrough: one line of code (:13). The ten lines above it (:3-12) are the contract: the summary states the rule and the leak it closes, the remarks state the shape the handler must return and why.
  • Why it's built this way: giving a zero-argument rule its own record type is what lets SponsorsController inject IQueryHandler<GetPublicSponsorFilterQuery, Result<Specification<Sponsor, SponsorIdentifierType>>> (MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:45) and reach the rule through the same pipeline as every other read, instead of calling a static helper from the API layer.
  • Where it's used: constructed once, inside the controller's GetReadSpecificationAsync override (MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:75), and answered by GetPublicSponsorFilterHandler.

ISponsorFieldsRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/ISponsorFieldsRequest.cs:12 · Level 0 · interface

  • What it is: the read-only shape of the eight sponsor fields that the create request and the update request validate identically. Both request records implement it, and it exists so the shared rule list can be declared once.
  • Depends on: nothing. Eight get-only properties over string, string?, and int (MMCA.ADC.Conference.Application/Sponsors/Validation/ISponsorFieldsRequest.cs:15-36); the file has no using directives at all.
  • Concept introduced: the shared field contract as a generic constraint. The rule sets in this slice are generic over the request type, which alone would still force every validator to restate Include(new SponsorNameRules<SponsorCreateRequest>(p => p.Name)) and its seven siblings, once per request record, with nothing but review discipline keeping the two lists aligned. This interface converts that discipline into a compiler check: SponsorFieldRules<T> constrains where T : ISponsorFieldsRequest (SponsorValidationRules.cs:141) and binds each selector through the interface member, so any record that implements it gets the whole shared set and cannot be bound to the wrong property. [Rubric §24, Forms/Validation/UX Safety] assesses whether every write entry point applies the same field constraints: here the "same" is enforced by the type system rather than by two parallel lists. [Rubric §1, SOLID]: it is an interface-segregation move, the smallest surface a validator needs, and it deliberately excludes the members that are not shared.
  • Walkthrough: the members are Name (:15, non-nullable, the only mandatory one), LogoUrl (:18), Description (:21), WebsiteUrl (:24), LinkedInUrl (:27), TwitterHandle (:30), Sort (:33, int), and BoothNumber (:36). Every property is get-only, which is enough for a validator that only reads and keeps the interface from becoming a mutation surface. What is absent carries the design: the <remarks> says the owning event stays off the interface because only the create request carries and validates it, moving a sponsor between events being a create plus a delete (:8-11). The nullability of each member is the same signal the rule sets read: SponsorNameRules<T> takes a non-nullable selector, the seven optional fields take string? selectors.
  • Why it's built this way: the alternative, a shared abstract request base class, would put a mutable inheritance chain into a DTO layer where records are meant to be flat and independently serializable. An interface constrains the generic without touching the wire shape or the model binder.
  • Where it's used: implemented by SponsorCreateRequest alongside ICreateRequest and ICacheInvalidating (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:12) and by SponsorUpdateRequest (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequest.cs:11); consumed as the generic constraint of SponsorFieldRules<T>. The activities slice has the identical construct in IActivityFieldsRequest.

ActivityUpdateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Update · MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:10 · Level 1 · record

  • What it is: the body a client PUTs to update an existing conference Activity, the non-session items on the agenda (receptions, breaks, after-parties). It carries every editable field and nothing else.
  • Depends on: IActivityFieldsRequest from MMCA.ADC.Conference.Application.Activities.Validation (MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequest.cs:1,10). Nothing else: DateTime, string, and int are BCL. There is not a single domain type in its member list, which is the point of a request DTO.
  • Concept introduced: what a full-replacement update body deliberately leaves out. Two members you might expect are absent, each for its own reason. There is no EventId, and the <remarks> says why: moving an activity between events is a create plus a delete, so a mistyped EventId cannot silently relocate a published social event (:6-9). That is the same rule SponsorUpdateRequest applies to bought sponsor placement, so the module has one consistent answer to "can a PUT reparent an aggregate?". There is also no concurrency token on the body: the version the caller edited travels in the HTTP If-Match header instead, read by SupportsIfMatchAttribute and passed to the command by the controller (MMCA.ADC.Conference.API/Controllers/Activities/ActivitiesController.cs:183,192,194-196), so a request without one answers 428 and a stale one answers 412 (ADR-035, stated in the action's own doc comment at :175-178). [Rubric §9, API & Contract Design] assesses whether a contract makes the safe thing the only expressible thing: the dangerous operation is not merely validated against, it is absent from the type, and the conditional-request machinery lives in the transport layer where HTTP already has semantics for it. [Rubric §4, DDD]: the owning event is part of the activity's identity within the conference, not an ordinary attribute, so it is not editable through the attribute-editing endpoint.
  • Walkthrough: public record class ActivityUpdateRequest : IActivityFieldsRequest (:10). Name (:13) is the one required member, so the model binder rejects a body without it before any validator runs. Description (:16) is optional. StartTime and EndTime (:19, :22) are plain DateTime values documented as event-local (:18, :21). VenueName (:25) is optional and documented such that empty means the main conference venue, with VenueAddress (:28) and VenueUrl (:31) alongside it for off-site items. SortOrder (:34) breaks ties between activities that start at the same time (:33). Every member is init-only, so the bound request is immutable for the rest of the pipeline, and every one of the eight satisfies a member of IActivityFieldsRequest.
  • Why it's built this way: making the PUT a full replacement rather than a patch means the applier can pass every field straight through to the aggregate without distinguishing "not supplied" from "cleared" (MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateApplier.cs:25-33). Marking only Name as required pushes the single non-negotiable field to bind time and leaves the graded constraints (lengths, the time ordering) to ActivityUpdateRequestValidator, which produces a readable message per field.
  • Where it's used: bound from the body by ActivitiesController on PUT {id} (MMCA.ADC.Conference.API/Controllers/Activities/ActivitiesController.cs:187-190), wrapped into UpdateEntityCommand<Activity, ActivityUpdateRequest, ActivityIdentifierType> with the If-Match token (:193-195), validated by ActivityUpdateRequestValidator through the command-to-request validator bridge, and applied field by field by ActivityUpdateApplier. It is also the TUpdateRequest argument of the module's CRUD registration (MMCA.ADC.Conference.Application/DependencyInjection.cs:144).
  • Caveats / not-in-source: nothing in the record pins a DateTimeKind, so whether the wire value arrives as UTC or unspecified local time is not determinable from this file: the doc comments only say "event-local" (:18, :21).

SponsorEventIdRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:115 · Level 1 · class

  • What it is: a one-rule reusable validator that asserts a sponsor request actually names an event. It is generic over the request type, so the same rule binds to any record that has an event id.
  • Depends on: RequiredIdRules<T, TId> from MMCA.Common.Application.Validation (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:4,116), Expression<Func<T, TProperty>> from the BCL (:1,118), and the EventIdentifierType alias (:116,118, aliased in the module's Shared project and taught in the primer).
  • Concept introduced: the parameterized rule set. This file is where sponsor validation is packaged rather than written inline, so learn the shape here. A rule set is a sealed class Foo<T> deriving from a MMCA.Common validation base, whose constructor takes an Expression<Func<T, TProperty>> selector and forwards it plus the field's contract to the base. It is generic because the contract belongs to the concept ("a sponsor's event id"), not to any one request record: SponsorCreateRequest is a different type from SponsorUpdateRequest, yet both could reuse the identical object by supplying their own property selector. Consumers fold it in with FluentValidation's Include(...), which merges the included validator's rules into the host validator as if they had been typed there. The payoff is that a constraint has exactly one definition and many bindings, so a change to it cannot land on the create path while missing the update path. [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are single-sourced and applied consistently at every entry point: this whole file exists to make that true for sponsors. [Rubric §1, SOLID]: each rule set has one reason to change, the contract of one field.
  • Walkthrough: sealed class SponsorEventIdRules<T> : RequiredIdRules<T, EventIdentifierType> (:115-116), with an expression-bodied constructor that forwards (selector, "an Event for the Sponsor", "Sponsor.EventId.Required") (:118-119). The base declares RuleFor(selector).NotEmpty().WithMessage($"You must specify {fieldName}") and applies the optional error code (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:145-147). Two details matter. First, NotEmpty on an identifier is the deliberate check, rejecting 0 for an integer key and Guid.Empty for a Guid key, which is what "an id was never supplied" looks like on the wire for both shapes (CommonValidationRules.cs:133-139). Second, the field phrase is interpolated verbatim, so the caller supplies the article: passing "an Event for the Sponsor" yields "You must specify an Event for the Sponsor". WithErrorCode is contract, not decoration: Sponsor.EventId.Required is what an API client or a test keys on, while the message is the human-facing half.
  • Why it's built this way: the XML doc states the business reason directly (:110-114), that sponsors are sold per event, so an unscoped sponsor has nowhere to appear. This rule is also the only enforcement of that fact at the application boundary: the Sponsor aggregate's Create composes name, logo URL, and booth number invariants but does not re-check the event id (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:120-122), because a zero-valued foreign key would already fail at the database.
  • Where it's used: included exactly once, by SponsorCreateRequestValidator as its create-only delta, with the reason written in a comment above the line (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:13-15). SponsorUpdateRequestValidator includes only the shared set (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:10), because SponsorUpdateRequest carries no event id at all.

SponsorSortRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:126 · Level 1 · class

  • What it is: the reusable rule that a sponsor's display order is not negative.
  • Depends on: NonNegativeIntRules<T> from MMCA.Common.Application.Validation (SponsorValidationRules.cs:4,127) and Expression<Func<T, int>> (:1,129). No Domain constant is involved, which is why it sits at Level 1 alongside SponsorEventIdRules<T> rather than with the length rules.
  • Concept introduced: none new; it is the parameterized rule set taught at SponsorEventIdRules<T>, applied to an integer. Worth noting is that the rule is a floor, not a range: GreaterThanOrEqualTo(0) (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:126) leaves the upper end open, so an organizer can insert a sponsor anywhere in a tier by choosing a large number rather than renumbering the rest. [Rubric §24, Forms/Validation/UX Safety]: the one constraint that has a real meaning is declared, and nothing more is invented.
  • Walkthrough: sealed class SponsorSortRules<T> : NonNegativeIntRules<T> (:126-127) with an expression-bodied constructor forwarding (selector, "Sort", "Sponsor.Sort.Negative") (:129-130). The base produces the message "Sort must be greater than or equal to 0" and stamps the error code on the rule (CommonValidationRules.cs:124-126).
  • Where it's used: not included directly by either request validator; it is folded into the shared bundle by SponsorFieldRules<T> (SponsorValidationRules.cs:146), which both validators include. That indirection is the reuse this file is built for.

SponsorBoothNumberRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:103 · Level 7 · class

  • What it is: the reusable length rule for a sponsor's optional expo booth number.
  • Depends on: OptionalStringRules<T> from MMCA.Common.Application.Validation (SponsorValidationRules.cs:4,104) and SponsorInvariants for the constant BoothNumberMaxLength (:3,107).
  • Concept introduced: the three-argument subclass, and where a length constant really lives. Four of the rule sets in this file (this one, description, name, Twitter handle) are a two-line sealed class whose constructor forwards to a shared MMCA.Common base with three arguments: the property selector, a human-facing field label, and a max length. The base does the work, RuleFor(selector).MaximumLength(maxLength) with a generated message (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:55-57). Two design points are worth internalizing. First, the subclass exists purely to name the pairing of a field with its constant, so callers write new SponsorBoothNumberRules<T>(p => p.BoothNumber) and cannot accidentally bind the booth-number field to the description's length. Second, follow the constant back and it does not stop at the Domain layer: SponsorInvariants.BoothNumberMaxLength is itself = SponsorDTO.BoothNumberMaxLength (MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:34), and the literal 50 is declared on the DTO in the Shared project (MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:36), which the invariants file explains is the lowest layer the UI can also reach, so markup and domain validation cannot drift apart (SponsorInvariants.cs:7-12). The EF configuration reads the same constant for the column width (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sponsors/SponsorConfiguration.cs:56). One number therefore governs the input control, the validator's message, the domain guard, and the column, so a 51-character booth number is rejected with a readable error instead of truncating or throwing at the database. That dependency on the Domain constant is why these seven sit at Level 7 while the two rule sets above sit at Level 1. [Rubric §8, Data Architecture] assesses whether storage constraints and application constraints agree; [Rubric §15, Best Practices & Code Quality]: widening a column is a one-constant change that propagates to every layer that cares.
  • Walkthrough: sealed class SponsorBoothNumberRules<T> : OptionalStringRules<T> (:103-104); the constructor takes Expression<Func<T, string?>> selector (:106) and calls : base(selector, "Booth Number", SponsorInvariants.BoothNumberMaxLength) (:107). There is no body. The selector type is nullable, which is the whole difference between the optional base and the required one: a null booth number passes.
  • Why it's built this way: the field is optional in the domain too. SponsorInvariants.EnsureBoothNumberIsValid delegates to CommonInvariants.EnsureOptionalStringMaxLength with the same constant and the error code Sponsor.BoothNumber.TooLong (SponsorInvariants.cs:64-65), and its doc comment records the deliberate rule that a booth number is accepted even when the sponsor is not flagged as an exhibitor, because the flag drives display and does not reject stored data (:57-60).
  • Where it's used: included by SponsorFieldRules<T> (SponsorValidationRules.cs:152), so it reaches both sponsor request validators; re-checked in the aggregate through SponsorInvariants.EnsureBoothNumberIsValid on both Create and Update (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:122,168).

SponsorDescriptionRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:43 · Level 7 · class

  • What it is: the reusable length rule for the optional sponsor description or blurb.
  • Depends on: OptionalStringRules<T> (SponsorValidationRules.cs:4,44) and SponsorInvariants.DescriptionMaxLength (:47, = SponsorDTO.DescriptionMaxLength at MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:22, literal 2000 at MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:24).
  • Concept introduced: none new; the three-argument subclass is taught at SponsorBoothNumberRules<T>. The field label passed to the base is "Sponsor Description" (:47), so the generated message reads "Sponsor Description cannot be longer than 2000 characters".
  • Walkthrough: sealed class SponsorDescriptionRules<T> : OptionalStringRules<T> (:43-44) with a single forwarding constructor (:46-47).
  • Where it's used: included by SponsorFieldRules<T> (SponsorValidationRules.cs:148).
  • Caveats / not-in-source: unlike name, logo URL, and booth number, the description has no corresponding Ensure... method in SponsorInvariants (SponsorInvariants.cs:42-65 declares only those three), so this rule set plus the EF column width (SponsorConfiguration.cs:34) are the enforcement; the aggregate does not re-check it.

SponsorLinkedInUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:74 · Level 7 · class

  • What it is: the reusable rule for the optional sponsor LinkedIn URL: bounded length plus an absolute http/https check, skipped entirely when the field is blank.
  • Depends on: AbstractValidator<T> from FluentValidation (SponsorValidationRules.cs:2,75), AbsoluteUrlRules<T> from MMCA.Common.Application.Validation (:4,82), and SponsorInvariants.LinkedInUrlMaxLength (:3,82, = SponsorDTO.LinkedInUrlMaxLength at SponsorInvariants.cs:28, literal 2000 at SponsorDTO.cs:30).
  • Concept introduced: the conditional rule set, and why a URL field is not just a bounded string. This is the first of the three URL rule sets in reading order, so learn the shape here; logo and website are the same construct. Unlike the length-only siblings it does not derive from a rule base, it contains one. The constructor compiles the selector once into an accessor (:79) and wraps the include in When(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new AbsoluteUrlRules<T>(...))) (:81-82), so an empty value passes untouched and a supplied value gets both the length bound and a scheme check. The check itself is the point: the base's Must(BeAnAbsoluteHttpUrl) delegates to CommonInvariants.EnsureUrlIsWellFormed (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:90,92-93), and the base's own remarks state the threat plainly, that bounded-string treatment accepts javascript: and data: values which become executable the moment a link or an image renders them (:77-83). The XML doc here repeats the local reason: the sponsor pages render the value straight into a link target (:68-73). [Rubric §11, Security] assesses whether untrusted input is constrained at the boundary in the shape it will be used: a stored URL is an injection vector, so the scheme is validated where the value enters, not where it renders. [Rubric §15, Best Practices & Code Quality]: the validator and the domain invariant answer through the same helper, so they cannot disagree.
  • Walkthrough: sealed class SponsorLinkedInUrlRules<T> : AbstractValidator<T> (:74-75); the constructor takes Expression<Func<T, string?>> selector (:77), compiles it to a delegate (:79), and registers the conditional include with the label "LinkedIn URL" (:81-82). Two mechanics are worth noticing: Include inside a When block applies the condition to every rule the included validator declares, and the accessor is compiled once at construction rather than per validation pass, since the rule set instance is built once per validator instance.
  • Why it's built this way: the length bound alone would have let a hostile value through while still fitting the column, and adding an ad-hoc Matches regex here would have created a second definition of "valid URL" that could drift from the domain's. Composing the shared AbsoluteUrlRules<T> keeps one definition and lets the blank case stay explicit and local.
  • Where it's used: included by SponsorFieldRules<T> (SponsorValidationRules.cs:150).
  • Caveats / not-in-source: the rule constrains the scheme and the length, not the host, so nothing here requires the value to point at linkedin.com, and there is no matching Ensure... method in SponsorInvariants (SponsorInvariants.cs:42-65), so the aggregate does not re-check it.

SponsorLogoUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:26 · Level 7 · class

  • What it is: the reusable rule for the optional sponsor logo URL: the same conditional length-plus-scheme check applied to the value that becomes an image source.
  • Depends on: AbstractValidator<T> (SponsorValidationRules.cs:2,27), AbsoluteUrlRules<T> (:4,34), and SponsorInvariants.LogoUrlMaxLength (:3,34, = SponsorDTO.LogoUrlMaxLength at SponsorInvariants.cs:19, literal 2000 at SponsorDTO.cs:21).
  • Concept introduced: none new; the conditional rule set is taught at SponsorLinkedInUrlRules<T>, although in file order this is the first occurrence (:26). The distinguishing note is the sink: the doc comment says the sponsor pages render the value straight into an image source (:20-23), so an accepted javascript: or data: value would execute in a page that never asked a user to click anything.
  • Walkthrough: sealed class SponsorLogoUrlRules<T> : AbstractValidator<T> (:26-27); constructor at :29-35 compiles the selector (:31) and registers When(...) around Include(new AbsoluteUrlRules<T>(selector, "Logo URL", SponsorInvariants.LogoUrlMaxLength)) (:33-34).
  • Why it's built this way: the domain still guards only the length here. SponsorInvariants.EnsureLogoUrlIsValid applies EnsureOptionalStringMaxLength with the error code Sponsor.LogoUrl.TooLong and documents that the value is a plain URL string with no upload pipeline behind it (SponsorInvariants.cs:47-55), so the scheme rule is enforced at the application boundary while the aggregate keeps the storage constraint.
  • Where it's used: included by SponsorFieldRules<T> (SponsorValidationRules.cs:147); the length half is re-checked in the aggregate through SponsorInvariants.EnsureLogoUrlIsValid on both Create and Update (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:121,167).
  • Caveats / not-in-source: a caller that constructs a Sponsor without going through the request pipeline gets the length check only; the scheme check lives in this rule set alone.

SponsorNameRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:13 · Level 7 · class

  • What it is: the reusable rule set for the sponsor's display name, the one sponsor string that is mandatory as well as bounded.
  • Depends on: RequiredStringRules<T> from MMCA.Common.Application.Validation (SponsorValidationRules.cs:4,14) and SponsorInvariants.NameMaxLength (:17, = SponsorDTO.NameMaxLength at SponsorInvariants.cs:16, literal 200 at SponsorDTO.cs:18).
  • Concept introduced: required versus optional, chosen by base class. This is the one string rule set in the file that derives from RequiredStringRules<T> rather than OptionalStringRules<T>, and that single choice is the whole difference in behavior. The required base chains NotEmpty() ahead of MaximumLength(...) and takes a non-nullable Expression<Func<T, string>> selector (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:43-46); the optional base takes a nullable selector and declares only the length rule (:55-57). So "is this field mandatory?" is answered once, by which base you extend, and the compiler helps: binding a string? property to this rule set will not compile, which is also why ISponsorFieldsRequest declares Name as non-nullable and the other seven as nullable or value types. [Rubric §1, SOLID]: two small bases, each with one responsibility, compose into every field contract in the module. [Rubric §24, Forms/Validation/UX Safety]: mandatory-ness is declared in one place per field rather than restated per request record.
  • Walkthrough: sealed class SponsorNameRules<T> : RequiredStringRules<T> (:13-14); the constructor takes Expression<Func<T, string>> selector (:16) and forwards (selector, "Sponsor Name", SponsorInvariants.NameMaxLength) (:17). The base produces two messages: "You must enter a Sponsor Name" and "Sponsor Name cannot be longer than 200 characters" (CommonValidationRules.cs:45-46). No error code is passed, so WithOptionalErrorCode leaves both rules coded by FluentValidation's default (CommonValidationRules.cs:30-32).
  • Why it's built this way: validation here is the fast, message-friendly first pass, not the authority. The Sponsor aggregate re-checks the same rule through SponsorInvariants.EnsureNameIsValid in both Create and Update (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:120,166), which returns Result errors carrying the stable codes Sponsor.Name.Empty and Sponsor.Name.TooLong (SponsorInvariants.cs:44-45). A caller that bypasses the request pipeline still cannot create a nameless sponsor.
  • Where it's used: included by SponsorFieldRules<T> as the first entry of the shared bundle (SponsorValidationRules.cs:145).

SponsorTwitterHandleRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:91 · Level 7 · class

  • What it is: the reusable length rule for the optional sponsor Twitter/X handle.
  • Depends on: OptionalStringRules<T> (SponsorValidationRules.cs:4,92) and SponsorInvariants.TwitterHandleMaxLength (:95, = SponsorDTO.TwitterHandleMaxLength at SponsorInvariants.cs:31, literal 100 at SponsorDTO.cs:33).
  • Concept introduced: none new; see SponsorBoothNumberRules<T>. Note the ceiling is 100 rather than the 2000 used for the URL fields, which is the point of naming each constant separately instead of sharing one "long text" limit.
  • Walkthrough: sealed class SponsorTwitterHandleRules<T> : OptionalStringRules<T> (:91-92) with one forwarding constructor passing the label "Twitter Handle" (:94-95).
  • Where it's used: included by SponsorFieldRules<T> (SponsorValidationRules.cs:151).
  • Caveats / not-in-source: nothing here normalizes the value, so whether a handle is stored with or without a leading @ is not constrained by this rule set and is not determinable from this file.

SponsorWebsiteUrlRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:56 · Level 7 · class

  • What it is: the reusable rule for the optional sponsor website URL: the third of the conditional length-plus-scheme rule sets.
  • Depends on: AbstractValidator<T> (SponsorValidationRules.cs:2,57), AbsoluteUrlRules<T> (:4,64), and SponsorInvariants.WebsiteUrlMaxLength (:3,64, = SponsorDTO.WebsiteUrlMaxLength at SponsorInvariants.cs:25, literal 2000 at SponsorDTO.cs:27).
  • Concept introduced: none new; see SponsorLinkedInUrlRules<T>. The doc comment gives the same sink reason, that the sponsor pages render the value straight into a link target (:50-55), and the label passed to the base is "Website URL" (:64).
  • Walkthrough: sealed class SponsorWebsiteUrlRules<T> : AbstractValidator<T> (:56-57); the constructor compiles the selector (:61) and registers the When guard around the included absolute-URL rules (:63-64).
  • Where it's used: included by SponsorFieldRules<T> (SponsorValidationRules.cs:149).
  • Caveats / not-in-source: no domain counterpart exists for this field (SponsorInvariants.cs:42-65), so the scheme and length rules here plus the EF column width (SponsorConfiguration.cs:38) are the enforcement.

SponsorFieldRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.Validation · MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:139 · Level 8 · class

  • What it is: the bundle of the eight sponsor field rules the create and the update request share, declared once over ISponsorFieldsRequest. Each concrete request validator includes this and adds only the rules its own operation needs.
  • Depends on: AbstractValidator<T> (SponsorValidationRules.cs:2,140), ISponsorFieldsRequest as its generic constraint (:141), and the eight rule sets it includes: SponsorNameRules<T> (:145), SponsorSortRules<T> (:146), SponsorLogoUrlRules<T> (:147), SponsorDescriptionRules<T> (:148), SponsorWebsiteUrlRules<T> (:149), SponsorLinkedInUrlRules<T> (:150), SponsorTwitterHandleRules<T> (:151), and SponsorBoothNumberRules<T> (:152).
  • Concept introduced: the shared bundle, and the create/update delta as one visible line. The parameterized rule sets only pay off if request validators are assembled from them, and this class is the assembly step for everything both sponsor operations have in common. Because T is constrained to ISponsorFieldsRequest, every selector is written against the interface member (p => p.Name, p => p.Sort, and so on), so the bundle binds to any implementing record without naming it. The instructive part is what is left out: this class does not include SponsorEventIdRules<T>, and neither does the update validator, so the difference between the two validators is a single extra Include on the create side with the reason written beside it (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:11-15 versus MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:10). Auditing "does the update path enforce everything the create path does?" is a two-file read with one line of difference. [Rubric §24, Forms/Validation/UX Safety] assesses whether every write entry point applies the same field constraints: the shared set is literally one shared object, and the only difference is structural rather than an oversight. [Rubric §15, Best Practices & Code Quality]: adding a sponsor field means adding one property to the interface, one rule set, and one Include here, after which both operations enforce it.
  • Walkthrough: sealed class SponsorFieldRules<T> : AbstractValidator<T> where T : ISponsorFieldsRequest (:139-141) with a parameterless constructor (:143-153) containing eight Include(new XRules<T>(p => p.Field)) statements in the order name, sort, logo URL, description, website URL, LinkedIn URL, Twitter handle, booth number. FluentValidation's Include merges each included validator's rules into this one, and this validator is in turn included by the request validators, so the rules flatten into a single rule list by the time the pipeline runs.
  • Why it's built this way: the alternative, repeating eight Include lines in each request validator, worked but made the two lists independent artifacts that had to be compared by eye. Hoisting them behind an interface constraint makes the shared set a single definition and reduces each request validator to its own delta, which is the same shape ActivityFieldRules<T> uses for activities.
  • Where it's used: included by SponsorCreateRequestValidator (SponsorCreateRequestValidator.cs:11) and SponsorUpdateRequestValidator (SponsorUpdateRequestValidator.cs:10). Neither is constructed by hand: ScanModuleApplicationServices calls FluentValidation's AddValidatorsFromAssembly(moduleAssembly) (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:252, invoked at MMCA.ADC.Conference.Application/DependencyInjection.cs:133), so the request validators are registered and resolved by the pipeline.

ActivityUpdateApplier

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Update · MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateApplier.cs:16 · Level 9 · class

  • What it is: the six-line class that turns an ActivityUpdateRequest into a call on the Activity aggregate's guarded Update method. It is the only activity-specific code on the update path; everything else is the framework's generic handler.
  • Depends on: IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType> from MMCA.Common.Application.Interfaces.Mapping (MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateApplier.cs:2,17), the Activity aggregate (:1,17,20), ActivityUpdateRequest (:17,20), Result from MMCA.Common.Shared.Abstractions (:3,20), and the ActivityIdentifierType alias (:17).
  • Concept introduced: the update applier, the one extension point of the generic write path. ADC no longer hand-writes an update handler per aggregate. The module registers services.AddEntityCrud<Activity, ActivityDTO, ActivityIdentifierType, ActivityCreateRequest, ActivityUpdateRequest>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:144), which binds ICommandHandler<UpdateEntityCommand<Activity, ActivityUpdateRequest, ActivityIdentifierType>, Result<ActivityDTO>> to the framework's UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> with TryAdd semantics (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:343-345). That generic handler loads the entity, stamps the row version, and saves, but it cannot know a field name, so it delegates the mutation to an injected IEntityUpdateApplier<...> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:50,84-92). This class is that delegate for activities, and it is registered by convention: ScanModuleApplicationServices scans the module assembly for IEntityUpdateApplier<,,> implementations and registers them scoped, with the comment stating the intent, that appliers wrap the aggregate's guarded mutation methods so the generic handler never has to know a field name (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:221-229). [Rubric §2, Design Patterns] assesses whether variation is isolated behind a small abstraction: the whole per-entity variation of an update is one method. [Rubric §4, DDD]: the applier calls the aggregate's method rather than assigning properties, so invariants and the domain event stay inside the aggregate. [Rubric §14, Testability]: the class has no dependencies at all, so it can be exercised with a plain in-memory Activity and no test host.
  • Walkthrough: sealed class ActivityUpdateApplier : IEntityUpdateApplier<Activity, ActivityUpdateRequest, ActivityIdentifierType> (:16-17). ApplyAsync (:20) is synchronous work returned as a completed task: it null-guards both arguments (:22-23) and returns Task.FromResult(entity.Update(...)) passing the eight request members positionally (:25-33). Everything downstream of that call belongs to the domain: Activity.Update combines the name, time-range, venue-name, venue-address, and venue-URL invariants and returns early on failure before mutating anything (MMCA.ADC.Conference.Domain/Activities/Activity.cs:155-162), then assigns the fields (:164-171) and raises ActivityChanged with DomainEntityState.Updated (:173). The refusal travels back as a Result, and the handler's base workflow stops the write before SaveChangesAsync. The <remarks> records the deliberate omission: the request carries no EventId, so the aggregate's parent reference is untouched here (:12-15).
  • Why it's built this way: the framework handler documents that no events are raised in the handler because domain events belong to the aggregate's own mutation methods, and that a handler publishing anything of its own would fire for the generic path and stay silent for a hand-written one (UpdateEntityHandler.cs:34-37). Keeping the applier a pure delegation preserves that property. It also keeps the repository out of reach: the generic handler takes its repository from IUnitOfWork and never constructor-injects one, because only the unit of work knows which physical data source the aggregate resolves to (UpdateEntityHandler.cs:39-41).
  • Where it's used: never constructed by hand. It is resolved as IEntityUpdateApplier<Activity, ActivityUpdateRequest, ActivityIdentifierType> into the generic handler that ActivitiesController injects as ICommandHandler<UpdateEntityCommand<Activity, ActivityUpdateRequest, ActivityIdentifierType>, Result<ActivityDTO>> (MMCA.ADC.Conference.API/Controllers/Activities/ActivitiesController.cs:43) and invokes from PUT {id} (:193-195). The sponsors slice has the same shape in SponsorUpdateApplier.

ActivityUpdateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Update · MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:7 · Level 9 · class

  • What it is: the FluentValidation validator for ActivityUpdateRequest. It owns no rules of its own: it is one Include of the shared activity field bundle.
  • Depends on: AbstractValidator<T> from FluentValidation (MMCA.ADC.Conference.Application/Activities/UseCases/Update/ActivityUpdateRequestValidator.cs:1,7) and ActivityFieldRules<T> from MMCA.ADC.Conference.Application.Activities.Validation (:2,10).
  • Concept introduced: none new; this is the shared-bundle composition taught at SponsorFieldRules<T>, on the activities side. What the pairing shows is the delta discipline in its smallest possible form: this validator is a single Include(new ActivityFieldRules<ActivityUpdateRequest>()) (:10), while ActivityCreateRequestValidator includes the same bundle plus ActivityEventIdRules<ActivityCreateRequest> with the reason in a comment (MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:11-15). Everything the create path enforces on a shared field, the update path enforces by construction, including the cross-field rules the bundle carries: ActivityFieldRules<T> includes a time-range rule set that takes two selectors, start and end, alongside the name, sort-order, description, and three venue rule sets (MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:135-141). [Rubric §5, Vertical Slice]: the validator lives in the UseCases/Update folder beside the request and applier it serves, not in a module-wide validators bucket.
  • Walkthrough: sealed class ActivityUpdateRequestValidator : AbstractValidator<ActivityUpdateRequest> (:7) with an expression-bodied constructor (:9-10). Three lines of body, no branching. Note the generic argument: the bundle is constrained where T : IActivityFieldsRequest (ActivityValidationRules.cs:131), which ActivityUpdateRequest satisfies (ActivityUpdateRequest.cs:10), so the compiler rejects a request record that is missing a shared field.
  • Why it's built this way: cross-field ordering (end after start) cannot be expressed by a per-property rule set, so it is packaged inside the bundle rather than written inline here. That keeps this class purely declarative: it cannot drift from the create path except by the one line that differs, which is visible in review.
  • Where it's used: never constructed by hand. ScanModuleApplicationServices calls AddValidatorsFromAssembly(moduleAssembly) (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:252, invoked at MMCA.ADC.Conference.Application/DependencyInjection.cs:133), so this validator is registered as IValidator<ActivityUpdateRequest>. AddEntityCrud then registers a CommandRequestValidator<TCommand, TRequest> bridging UpdateEntityCommand<Activity, ActivityUpdateRequest, ActivityIdentifierType> to it (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:351-353), and ValidatingCommandDecorator<TCommand, TResult> runs it before the handler.

GetPublicSponsorFilterHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.GetPublicSponsorFilter · MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:16 · Level 11 · class

  • What it is: the handler for GetPublicSponsorFilterQuery. It asks the shared visibility helper for the published event ids and returns a Sponsor.EventId IN (...) specification built from them.
  • Depends on: PublicConferenceVisibility from MMCA.ADC.Conference.Application.Common (MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:1,25), IQueryHandler<in TQuery, TResult> (:4,18), IUnitOfWork (:3,17), Specification<TEntity, TIdentifierType> and InlineSpecification<TEntity, TIdentifierType> (:5,30), Sponsor (:2,18), and Result (:6,29).
  • Concept introduced: the shortest public-filter handler, and what a real foreign-key column buys you. Its speaker and session siblings have to translate a visibility rule into an id list of the entity they are filtering, because those aggregates cannot express the rule against a column of their own. Sponsor does carry EventId (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45), so the rule collapses to one hop: fetch the published event ids and compare the sponsor's own column against them. Two design points carry over from the siblings anyway. First, the id list comes from PublicConferenceVisibility, not from a local query, so "published" (BR-108) is defined in exactly one method for every public conference read, and closing a leak there closes it everywhere (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-15,36-48). Second, the returned criteria contain no navigation join, so they stay translatable on any provider. [Rubric §11, Security] assesses whether visibility is centrally defined and server-derived: a caller supplies nothing, and the published-event rule lives in one place. [Rubric §1, SOLID]: the handler's only job is to shape the helper's output into a specification. [Rubric §8, Data Architecture]: the IN predicate is engine-portable per ADR-018.
  • Walkthrough: the primary constructor takes only the unit of work (:16-17); the class implements IQueryHandler<GetPublicSponsorFilterQuery, Result<Specification<Sponsor, SponsorIdentifierType>>> (:18). HandleAsync (:21-23) awaits PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken) (:25-27), which reads through IUnitOfWork.GetReadRepository and projects Event.Id where IsPublished with asTracking: false (PublicConferenceVisibility.cs:40-44), then materializes the result once so the predicate closes over a stable collection EF can translate (:46-47). It then returns Result.Success wrapping an InlineSpecification<Sponsor, SponsorIdentifierType>(s => publishedEventIds.Contains(s.EventId)) (:29-30). There is no failure path and no branching: an empty published set simply yields a specification that matches nothing.
  • Why it's built this way: routing every public read filter through one helper rather than through per-entity queries is the deliberate anti-leak measure recorded in the helper's own summary (PublicConferenceVisibility.cs:10-15), and returning a specification (rather than sponsor rows) lets the controller hand the filter to the shared query service and let it AND the filter with the caller's own criteria.
  • Where it's used: injected into SponsorsController's primary constructor (MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:45) and called from its GetReadSpecificationAsync override (:68-78), which is the framework's single read hook: it short-circuits to null for privileged readers (Organizer or ContentEditor, :52,71-72) and otherwise supplies the specification to every read action, so list, paged, lookup, and by-id are all scoped from one place and a sponsor the filter excludes is a 404 rather than a redacted record (:55-66). Because the specification is ANDed by the query service rather than substituted, scoping a request to an unpublished event yields an empty page for a non-privileged caller instead of leaking the roster (:90-95).
  • Caveats / not-in-source: when the handler returns a failure the override falls back to null, meaning no filter (:77). The handler has no failure path today, so that branch is unreachable from this call site; whether it is defensive by intent is not determinable from source.

CategoryItemSortRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.Validation · MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:41 · Level 1 · class

  • What it is: a two-line FluentValidation rule set stating that a category item's display sort order may not be negative, bound to whichever int property the caller points it at.
  • Depends on: NonNegativeIntRules<T> from MMCA.Common.Application.Validation, which it derives from (ConferenceCategoryValidationRules.cs:5,42), and Expression<Func<T, int>> from System.Linq.Expressions (:2,44). FluentValidation's AbstractValidator<T> arrives through the base class, not through a direct reference.
  • Concept introduced: the selector-parameterized rule set, specialized by inheritance. A rule set in this codebase is a validator generic over the request type T whose constructor takes an expression naming the property to check. That is what lets one rule serve unrelated request shapes: AddCategoryItemCommand and UpdateCategoryItemCommand share no base type, yet both hand p => p.Sort to this class. Three specialization styles sit in this one file. ConferenceCategoryTitleRules<T> and CategoryItemNameRules<T> write their RuleFor chain out in full; this type instead subclasses a Common rule set and supplies nothing but vocabulary, a field phrase and an error code (:44-45). Inheritance is the right tool here precisely because the predicate is already generic (any non-negative int) and only the naming is local. [Rubric §1, SOLID] assesses whether a type has one reason to change: the bound lives in Common and this subclass changes only when the field is renamed. [Rubric §24, Forms/Validation/UX Safety] assesses whether every inbound field carries a contract at the boundary rather than only in the UI: a negative sort order is refused server-side. [Rubric §15, Best Practices & Code Quality]: the check exists once for the whole workspace.
  • Walkthrough: sealed class CategoryItemSortRules<T> : NonNegativeIntRules<T> (:41-42). The constructor (:44-45) is an expression-bodied base call passing the selector plus the two literals "Sort order" and "CategoryItem.Sort.Negative", and that is the entire body. The behavior lives in the base (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:122-126): RuleFor(selector).GreaterThanOrEqualTo(0) with the message "{fieldName} must be greater than or equal to 0" and WithOptionalErrorCode(errorCode), so a caller that passes no code gets FluentValidation's default and this caller gets the stable one.
  • Why it's built this way: the comparison is GreaterThanOrEqualTo(0) and not GreaterThan(0) because zero is the legitimate first position in a sort sequence, so the rule has to admit it. Splitting the human message from a machine-readable code is the same split used everywhere in this module: the message is what an organizer reads, the code is what a client branches on, and only the code is contractual.
  • Where it's used: AddCategoryItemCommandValidator (MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:12) and UpdateCategoryItemCommandValidator (MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:12). Those are the only two binding sites in the module; both commands declare Sort as a positional int (AddCategoryItemCommand.cs:18, UpdateCategoryItemCommand.cs:18).

EventUpdateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequest.cs:7 · Level 1 · record

  • What it is: the full-replacement payload for editing a conference Event: identity and schedule (name, description, the two dates, the time zone), the Sessionize link code, the venue and logistics fields an attendee sees, the per-event question moderation default, and the outward-facing contact and URLs an edition publishes.
  • Depends on: IEventFieldsRequest from MMCA.ADC.Conference.Application.Events.Validation, which it implements (EventUpdateRequest.cs:1,7), and the QuestionModerationDefault enum from MMCA.ADC.Conference.Shared.Events (:2,37). Every other member is a BCL primitive, including DateOnly for the two dates (:16,19).
  • Concept introduced: the precondition is not in the body. This record carries no RowVersion member at all. The caller's last-observed optimistic-concurrency token travels in the HTTP If-Match header, is decoded by SupportsIfMatchAttribute into the request's Items bag, and is read back by the action through SupportsIfMatchAttribute.RequiredToken(HttpContext) (MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:68-76; the call site is MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:230). The consequence for this type is that it stays a pure domain payload: it cannot express "I do not know what I read", because a request that states no precondition is answered 428 Precondition Required by the filter and never reaches the action at all (EventsController.cs:220-223). [Rubric §9, API & Contract Design] assesses whether the protocol's own semantics are used where they exist: conditional requests are an HTTP feature, and modeling them as a nullable body field would have made the guarantee opt-out. [Rubric §8, Data Architecture] assesses how concurrent writes to one row are reconciled: optimistic concurrency with a caller-supplied token, per ADR-035, rather than pessimistic locking or last-write-wins.
  • Concept introduced: date-only scheduling plus a named time zone, rather than an offset. The event's span is two DateOnly values (:16,19) and its TimeZone is a string documented as an IANA identifier (:21-22). Nothing here is a DateTimeOffset, so the record cannot bake in a UTC offset that is wrong half the year: the calendar day is the fact, and the zone id is how a consumer resolves a wall-clock session time to an instant. That is also what makes the time zone load-bearing enough to earn its own advisory rule on the update path (BR-131, see UpdateEventHandler). [Rubric §27, i18n]: an IANA id is the portable, culture-neutral way to express when this conference happens, and it is checked against the host's time zone database by EventTimeZoneRules<T> (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:25,41,44).
  • Walkthrough: four members are required and therefore cannot be omitted by a caller: Name (:10), StartDate (:16), EndDate (:19) and TimeZone (:22). The optional strings are Description (:13), SessionizeCode (:25, the code tying this edition to a Sessionize event feed), VenueAddress (:28), VenueMapUrl (:31), WiFiInfo (:34), OrganizerContactEmail (:40), SponsorshipPacketUrl (:43) and TicketingUrl (:46). QuestionModerationDefault (:37) is the one enum member, documented as the BR-233 moderation default for live-layer session questions. Every member is init-only, and seven of them satisfy the get-only contract of IEventFieldsRequest (MMCA.ADC.Conference.Application/Events/Validation/IEventFieldsRequest.cs:12-34), which is what lets one shared rule set validate both the create and the update body.
  • Why it's built this way: the update request carries one field the create request does not. EventCreateRequest implements the same interface (MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:11) but has no QuestionModerationDefault member, and EventCreateRequestMapper skips that argument entirely when it calls the factory (MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestMapper.cs:19-32), so a new event takes the domain default Pending (MMCA.ADC.Conference.Domain/Events/Event.cs:181; enum members at MMCA.ADC.Conference.Shared/Events/Live/QuestionModerationDefault.cs:10,13). Moderation posture is therefore something an organizer opts into after the event exists rather than a decision forced at creation time, and the cautious value is the one you get by default. [Rubric §11, Security] assesses whether defaults fail safe: unmoderated display of attendee-submitted text is the riskier state, and it is never the implicit one.
  • Where it's used: bound from the body by EventsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:220-228), validated by EventUpdateRequestValidator, wrapped together with the If-Match token in UpdateEventCommand (:232), and passed field by field into Event.Update by UpdateEventHandler (UpdateEventHandler.cs:59-72).

SessionUpdateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequest.cs:6 · Level 1 · record

  • What it is: the full-replacement payload a client PUTs to edit an existing Session. It carries the parent event id, the title and description, the scheduled window, four booleans describing what kind of slot this is, four optional strings (live URL, recording URL, accessibility info, resource links), and the optional room assignment.
  • Depends on: ISessionFieldsRequest from MMCA.ADC.Conference.Application.Sessions.Validation, which it implements (SessionUpdateRequest.cs:1,6). Every other member is a BCL primitive or one of the module's identifier aliases (EventIdentifierType at :9, RoomIdentifierType at :51), so the record pulls in no domain type at all. Like EventUpdateRequest it has no RowVersion member: the precondition arrives in the If-Match header (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:320,329).
  • Concept introduced: carrying a field you are not allowed to change, so the server can detect that you tried. EventId is required here (:9) even though a session can never move between events, and the doc comment on the property says exactly that ("Must match the session's current EventId (BR-140: immutable after creation)", :8). The rule is enforced downstream by UpdateSessionHandler, which compares the request value against the loaded entity and fails with an unprocessable-entity error coded Session.EventId.Immutable (MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:45-52). Carry-and-verify is the right choice when the field is genuinely part of the resource's identity in the client's mental model: a session editor already knows which event it is working under, so sending it costs nothing and turns a client bug (posting session 42's body to session 43's route) into a loud rejection rather than a silent cross-event write. [Rubric §9, API & Contract Design] assesses whether a contract makes illegal states detectable rather than merely undocumented: the field's presence is what makes the mismatch checkable at all. [Rubric §11, Security]: a request body is caller-controlled, so an immutability rule that exists only in a UI is not a rule; the check that counts is the server-side comparison.
  • Walkthrough: two members are required, EventId (:9) and Title (:12). Description (:15) is optional. The schedule is two nullable DateTime values, StartsAt (:18) and EndsAt (:21), so an unscheduled session is a legal state. Status (:24) is a free-form optional string. Four booleans describe the slot: IsInformed (:27) and IsConfirmed (:30) track the speaker-communication workflow, IsServiceSession (:33) marks lunch and break blocks, and IsPlenumSession (:36) marks whole-room slots. Four optional strings follow: LiveUrl (:39), RecordingUrl (:42), AccessibilityInfo (:45) and ResourceLinks (:48). RoomId (:51) is the nullable room assignment, and it is the one member that triggers cross-aggregate work in the handler. Every member is init-only; seven of them satisfy ISessionFieldsRequest (MMCA.ADC.Conference.Application/Sessions/Validation/ISessionFieldsRequest.cs:13-35), whose remarks record why EventId deliberately stays off that interface (:8-12).
  • Why it's built this way: nullable StartsAt/EndsAt are load-bearing rather than lazy. Sessions exist before the schedule is drawn, so "no time yet" must round-trip through the edit form without inventing a placeholder date; SessionRoomScheduling reads the same nullability and simply skips the double-booking probe when either bound is missing (MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:69-70). Because the PUT is a full replacement, the handler passes all fourteen editable fields straight into Session.Update (MMCA.ADC.Conference.Domain/Sessions/Session.cs:235-249) without ever distinguishing "omitted" from "cleared".
  • Where it's used: bound from the body by SessionsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:319-328), validated by SessionUpdateRequestValidator, wrapped in UpdateSessionCommand (:331), and consumed field by field by UpdateSessionHandler.

UpdateEventResult

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:25 · Level 3 · record

  • What it is: the two-member envelope UpdateEventHandler returns: the updated EventDTO plus a boolean saying whether this particular update changed the time zone while sessions already existed.
  • Depends on: EventDTO from MMCA.ADC.Conference.Shared.Events (UpdateEventCommand.cs:2,25). Nothing else; the second member is a bool.
  • Concept introduced: the advisory result, distinct from success and from failure. The Result pattern gives a handler two outcomes, success with a value or failure with errors. BR-131 is neither: changing an event's time zone after sessions are scheduled does not violate an invariant (the write is legitimate and must be persisted), but it does change what every already-stored session time means. Rejecting it would be wrong, and silently accepting it would be worse. The answer here is a third channel carried inside the success value, a flag the caller can act on. This is a small type with a large lesson, namely that "succeeded, with something you should know" deserves a first-class shape rather than a log line the operator never reads. [Rubric §9, API & Contract Design] assesses how non-fatal conditions are conveyed: EventsController translates the flag into an X-Warning response header and still returns 200 with the DTO (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:240-248), so the body stays exactly the EventDTO the API contract promises and the advisory rides beside it. [Rubric §13, Observability & Operability]: the condition is surfaced to the human who caused it, at the moment they caused it.
  • Walkthrough: one line. sealed record UpdateEventResult(EventDTO Event, bool HasTimeZoneWarning) (:24), with both members documented on the declaration (:21-23). It has no methods and no behavior; it exists to name a pair.
  • Why it's built this way: it lives in the same file as UpdateEventCommand (:15) because the two are one use case's input and output and are never referenced apart. Keeping the envelope in the Application layer rather than widening EventDTO with a HasTimeZoneWarning property matters: the flag is a fact about this write, not a property of the event, so it must not be persisted, cached, or returned by any read.
  • Where it's used: constructed by UpdateEventHandler in its BuildResult override (UpdateEventHandler.cs:80-86), named as the fourth type argument of the handler's base class (:22) and in the controller's injected dependency (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:50), and unwrapped by the controller, which reads the flag (:239) and then returns only result.Value.Event (:247). No other layer sees the envelope.

UpdateSessionResult

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:25 · Level 3 · record

  • What it is: the two-member envelope UpdateSessionHandler returns: the updated SessionDTO plus a boolean saying whether the new session times fall outside the parent event's date range.
  • Depends on: SessionDTO from MMCA.ADC.Conference.Shared.Sessions (UpdateSessionCommand.cs:2,25). The second member is a bool.
  • Concept introduced: none new. This is the advisory-result shape taught at UpdateEventResult, applied to a second rule: BR-86 rather than BR-131. What is worth carrying forward is that the shape recurs, which is what makes it a pattern rather than a one-off. Both cases share the same class of problem: the write is legal and must be persisted, but it leaves the data in a state a human should look at. Scheduling a session outside the conference dates is not an invariant violation (the organizer may be mid-edit, or the event dates may be about to move), so failing the request would be wrong, and swallowing it would leave a session nobody can attend. [Rubric §9, API & Contract Design]: SessionsController reads the flag, appends an X-Warning response header and still returns 200 with only the DTO in the body (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:339-345), so the envelope itself never reaches the wire. [Rubric §13, Observability & Operability]: the warning reaches the person who caused it, at the moment they caused it.
  • Walkthrough: one line, sealed record UpdateSessionResult(SessionDTO Session, bool HasDateRangeWarning) (:24), documented on the declaration (:21-23). No methods, no behavior; it exists to name a pair.
  • Why it's built this way: it shares a file with UpdateSessionCommand (:15) because the two are one use case's input and output and are never referenced apart. Keeping the flag out of SessionDTO is the load-bearing part: HasDateRangeWarning is a fact about this particular write, not a property of the session, so it must never be persisted, cached, or returned by a read endpoint.
  • Where it's used: constructed by UpdateSessionHandler in its BuildResult override (UpdateSessionHandler.cs:110-116), named as the fourth type argument of the handler's base class (:23) and in the controller's injected handler type (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:48), and unwrapped by the controller, which reads result.Value!.HasDateRangeWarning (:338) and then returns result.Value.Session (:344).
  • Caveats / not-in-source: the create path reaches the same header by a different route. It has no result envelope: the controller itself re-reads the parent event through the event query service and compares the requested times inline (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:292-305). Only the update path routes the flag through a handler result.

CategoryItemNameRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.Validation · MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:28 · Level 7 · class

  • What it is: the reusable rule set for a category item's name: non-empty, and no longer than the domain's declared maximum.
  • Depends on: FluentValidation's AbstractValidator<T>, which it derives from directly (ConferenceCategoryValidationRules.cs:3,29), CategoryInvariants from the Domain layer for the length bound (:4,34), Expression<Func<T, string>> (:2,31) and CultureInfo (:1,34).
  • Concept introduced: the length constant is read, never retyped. MaximumLength(CategoryInvariants.CategoryItemNameMaxLength) (:34) resolves through CategoryInvariants.CategoryItemNameMaxLength (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:24) to CategoryItemDTO.NameMaxLength, which is the const int 500 that also feeds the Blazor input caps and the EF column width (MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:16; the arrangement is documented on the invariants class itself, CategoryInvariants.cs:10-13). The same constant is then interpolated into the user-facing message with string.Create(CultureInfo.InvariantCulture, ...) (:34), so the number in the error text can never drift from the number being enforced. [Rubric §15, Best Practices & Code Quality] assesses whether one decision lives in one place: widening the field is a one-constant change that propagates to the validator message, the aggregate guard and the schema. [Rubric §27, i18n]: the explicit invariant culture is required by the analyzer baseline and makes the interpolation deterministic; these messages are not localized, which is a stated limit rather than an oversight.
  • Walkthrough: sealed class CategoryItemNameRules<T> : AbstractValidator<T> (:28-29). The constructor (:31-34) is a single expression-bodied RuleFor(selector) chain with two links: NotEmpty() carrying the message "You must enter a Category Item Name" and the code CategoryItem.Name.Required (:33), then MaximumLength(...) with the interpolated message and the code CategoryItem.Name.MaxLength (:34). Every link states its own WithErrorCode, so a client can distinguish "missing" from "too long" without parsing English.
  • Why it's built this way: what is not here matters as much as what is. Uniqueness of a category item name inside its parent (BR-138) cannot be a field rule, because it needs the sibling collection, so it lives on the aggregate as CategoryInvariants.EnsureCategoryItemNameIsUnique (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:44-65). The split is the standard one in this codebase: shape checks at the boundary, relationship checks in the domain. [Rubric §4, DDD] assesses whether invariants that require aggregate state stay inside the aggregate.
  • Where it's used: AddCategoryItemCommandValidator (MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:11) and UpdateCategoryItemCommandValidator (MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:11), each pairing it with CategoryItemSortRules<T> over the same command.

ConferenceCategoryTitleRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.Validation · MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:14 · Level 7 · class

  • What it is: the reusable rule set for a conference Category title: non-empty, and no longer than the domain's declared maximum.
  • Depends on: FluentValidation's AbstractValidator<T> (ConferenceCategoryValidationRules.cs:3,15), CategoryInvariants for the bound (:4,20), Expression<Func<T, string>> (:2,17) and CultureInfo (:1,20).
  • Concept introduced: none new; it is the same shape as CategoryItemNameRules<T> one field up the aggregate. What this pair demonstrates is the payoff: because the rule is a type rather than an inline chain, the create and the update entry paths bind the same object, so a title contract cannot diverge between "add a category" and "edit a category". [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are single-sourced and applied consistently across entry paths; this is the smallest complete example of it in the module.
  • Walkthrough: sealed class ConferenceCategoryTitleRules<T> : AbstractValidator<T> (:14-15). The constructor (:17-20) chains NotEmpty() with the message "You must enter a Category Title" and the code Category.Title.Required (:19), then MaximumLength(CategoryInvariants.TitleMaxLength) with an invariant-culture interpolated message and the code Category.Title.MaxLength (:20). The bound resolves to ConferenceCategoryDTO.TitleMaxLength, the const int 255 (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:18; MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:17).
  • Why it's built this way: the boundary rule and the domain guard are deliberately both present and deliberately not shared code. CategoryInvariants.EnsureTitleIsValid re-checks emptiness and the same length inside the aggregate (CategoryInvariants.cs:26-29) with its own error codes (Category.Title.Empty, Category.Title.TooLong). The validator exists so a bad request is refused before a handler runs and the client gets a field-level message; the invariant exists so the aggregate is correct no matter who calls it, including an importer or a test. They agree because both read the one constant. [Rubric §3, Clean Architecture] assesses whether the inner layer stays independently correct rather than trusting its callers.
  • Where it's used: ConferenceCategoryCreateRequestValidator (MMCA.ADC.Conference.Application/Categories/UseCases/Create/ConferenceCategoryCreateRequestValidator.cs:10) and ConferenceCategoryUpdateRequestValidator (MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:10), each binding it to its own request's Title.

UpdateEventCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventCommand.cs:16 · Level 8 · record

  • What it is: the write intent for updating an Event: the route id, the EventUpdateRequest body, and the caller's If-Match concurrency token, marked as cache-invalidating.
  • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating from MMCA.Common.Application.UseCases (UpdateEventCommand.cs:3,4,16), EventUpdateRequest (:15), and the Event aggregate type, used only for its FullName in the cache prefix (:1,18). EventIdentifierType is the module identifier alias.
  • Concept introduced: the concurrency token as a command parameter. byte[] RowVersion is the third positional member and it is non-nullable (:15). The doc comment states the contract in full: the token is the caller's last-observed version, read from the If-Match header, and it is required because a conditional update that states no precondition never reaches this command (:10-14). Making it a parameter of the command rather than a property of the request has two effects worth internalizing. First, the Application layer stays HTTP-agnostic while still receiving the precondition: the header is decoded at the boundary and handed in as data. Second, the type system carries the guarantee. There is no legal way to construct this command without a token, so no handler has to consider the "caller declined to check" case. [Rubric §6, CQRS & Event-Driven] assesses whether writes are explicit intents carrying everything the handler needs; [Rubric §9, API & Contract Design]: 428 for a missing precondition and 412 for a stale one are decided at the boundary and never leak into the command.
  • Walkthrough: sealed record UpdateEventCommand(EventIdentifierType Id, EventUpdateRequest Request, byte[] RowVersion) implementing both marker interfaces on the declaration line (:15). The single member is CachePrefix => $"{typeof(Event).FullName}:" (:18), the key namespace the caching decorator evicts on success. UpdateEventResult sits directly below in the same file (:24).
  • Why it's built this way: the command does not implement ITransactional. Its handler writes one aggregate and saves once, so the ambient SaveChangesAsync boundary suffices, and the transactional decorator is reserved for commands that coordinate multiple aggregates (ADR-014; see TransactionalCommandDecorator<TCommand, TResult>). Deriving the cache prefix from typeof(Event).FullName rather than a literal keeps the writer and the readers agreed on a key namespace that a rename cannot desynchronize.
  • Where it's used: constructed by EventsController from the route id, the body and the decoded token (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:230-234) against the injected ICommandHandler<UpdateEventCommand, Result<UpdateEventResult>> (:49), and handled by UpdateEventHandler.

EventUpdateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/EventUpdateRequestValidator.cs:7 · Level 9 · class

  • What it is: the validator for EventUpdateRequest. It includes the shared event field rules as one unit and adds exactly one rule of its own.
  • Depends on: FluentValidation's AbstractValidator<T> (EventUpdateRequestValidator.cs:1,7), EventUpdateRequest (:7), and EventFieldRules<T> from MMCA.ADC.Conference.Application.Events.Validation (:2,11).
  • Concept introduced: the shared shape as one include, the per-operation delta inline. EventFieldRules<T> is itself a validator that includes the six rule sets every event body shares, declared once over IEventFieldsRequest (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:139-152): name, IANA time zone, date range, organizer email, sponsorship packet URL, ticketing URL. This validator includes that one type (:11), and EventCreateRequestValidator includes the same one, so the two entry paths cannot drift by an include. What remains in each file is only its delta. Here that is RuleFor(x => x.QuestionModerationDefault).IsInEnum() (:15-18), with the comment above it recording exactly why (:13-14): the moderation default is settable only on an existing event, so the create request neither carries nor validates it. The rule itself guards the way enums arrive over JSON, where an unmapped integer binds happily into an enum-typed property; IsInEnum rejects a value outside the two declared members before the handler runs, with the stable code Event.QuestionModerationDefault.Invalid (:18; enum members at MMCA.ADC.Conference.Shared/Events/Live/QuestionModerationDefault.cs:10,13). [Rubric §24, Forms/Validation/UX Safety] assesses whether every inbound field has a contract; [Rubric §11, Security]: an enum is a closed set only if something enforces the closure at the boundary. [Rubric §1, SOLID]: the class's own job is now composition plus one rule, so a name-length change is never found in two files.
  • Walkthrough: sealed class EventUpdateRequestValidator : AbstractValidator<EventUpdateRequest> (:7). The constructor (:9-19) is two statements: the include (:11) and the enum rule (:15-18). Everything the include pulls in is generic over T : IEventFieldsRequest (EventValidationRules.cs:141), which is the constraint EventUpdateRequest satisfies by implementing the interface. Fields with no rule at all: Description, SessionizeCode, VenueAddress, VenueMapUrl and WiFiInfo.
  • Why it's built this way: length bounds are not literals anywhere on this path. Each included rule set reads its constant from EventInvariants in the Domain layer, so the validator message, the aggregate guard and the EF column width agree on one number. [Rubric §15, Best Practices & Code Quality]: widening a field is a one-constant change.
  • Caveats / not-in-source: the time zone check calls TimeZoneInfo.FindSystemTimeZoneById and treats TimeZoneNotFoundException as invalid (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:41,44), so the accepted set is whatever the host's time zone database contains. Which identifiers that is on a given container image is not determinable from source.
  • Where it's used: registered by assembly scanning, reached through UpdateEventCommand's ICommandWithRequest<out TRequest> implementation, and executed by the ValidatingCommandDecorator<TCommand, TResult> before UpdateEventHandler runs.

SessionUpdateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequestValidator.cs:7 · Level 9 · class

  • What it is: the validator for SessionUpdateRequest. It is one statement: include the shared session field rules and add nothing.
  • Depends on: FluentValidation's AbstractValidator<T> (SessionUpdateRequestValidator.cs:1,7), SessionUpdateRequest (:7), and SessionFieldRules<T> from MMCA.ADC.Conference.Application.Sessions.Validation (:2,12).
  • Concept introduced: none new; this is the include-the-shared-shape composition taught at EventUpdateRequestValidator, here with an empty delta. The instructive part is the diff against the create side. SessionCreateRequestValidator includes the same SessionFieldRules<T> and then one more rule set, SessionEventIdRules<T> (MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestValidator.cs:11,16), because on create EventId is the field that decides where the session lands, so "you must specify an Event for the Session" is a real field-level rule. On update it is a value to be compared, not chosen, so a non-empty check would add nothing and the real guard is the handler's equality test. This file says so in a comment rather than leaving the absence to be inferred (:9-10), which is the codebase's habit for a deliberate omission. [Rubric §24, Forms/Validation/UX Safety]: the shared constraints are single-sourced and each path documents only its own difference. [Rubric §1, SOLID]: this class has exactly one reason to change, and it is not a title-length change.
  • Walkthrough: sealed class SessionUpdateRequestValidator : AbstractValidator<SessionUpdateRequest> (:7); the constructor is expression-bodied and consists of the single Include(new SessionFieldRules<SessionUpdateRequest>()) (:11-12). That one type includes seven rule sets over ISessionFieldsRequest (MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:111-124): SessionTitleRules<T>, SessionDescriptionRules<T>, SessionStatusRules<T>, SessionLiveUrlRules<T>, SessionRecordingUrlRules<T>, SessionAccessibilityInfoRules<T> and SessionResourceLinksRules<T>. Fields with no rule at all: EventId, StartsAt, EndsAt, the four booleans and RoomId.
  • Why it's built this way: the two URL fields are length-checked but not format-checked. An imported Sessionize feed is the authority on what a live-stream link looks like, so rejecting a shape the upstream system accepts would break the import rather than protect anyone. All bounds are read from SessionInvariants in the Domain layer, so the message, the aggregate guard and the column width agree on one number.
  • Caveats / not-in-source: the start/end ordering rule is not here. StartsAt and EndsAt carry no validator rule; the ordering invariant is enforced inside the aggregate by SessionInvariants.EnsureEndsAtIsAfterStartsAt, called from Session.Update (MMCA.ADC.Conference.Domain/Sessions/Session.cs:253). A reader asking "why was my end-before-start rejected" must look at the domain, not at this file. The room rules (BR-130) are likewise absent, because they need the parent event and the other sessions; they live in SessionRoomScheduling, invoked from the handler.
  • Where it's used: discovered by assembly scanning, reached through UpdateSessionCommand's ICommandWithRequest<out TRequest> implementation, and executed by the ValidatingCommandDecorator<TCommand, TResult> before UpdateSessionHandler runs.

UpdateSessionCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionCommand.cs:16 · Level 9 · record

  • What it is: the write intent for updating a Session: the target id, the SessionUpdateRequest payload and the caller's If-Match token, opted into cache eviction.
  • Depends on: ICommandWithRequest<out TRequest> and ICacheInvalidating (UpdateSessionCommand.cs:3,4,16), the SessionUpdateRequest it wraps (:15), and the Session type used only for its FullName in the cache prefix (:1,18). The file also declares UpdateSessionResult (:24), which is why it imports SessionDTO (:2).
  • Concept introduced: none new; this is the id-plus-request-plus-token shape taught at UpdateEventCommand, byte for byte including the doc comment on RowVersion (:10-14). What is worth noting here is what the two marker interfaces buy and what they do not. ICacheInvalidating opts the command into the CachingCommandDecorator<TCommand, TResult> so a successful update evicts the session read cache under this prefix, and ICommandWithRequest<out TRequest> is what routes SessionUpdateRequestValidator into the pipeline. Neither is ITransactional, so this command runs without an explicit ambient transaction; the base handler's single SaveChangesAsync is its own unit of work, which suffices because the write touches one aggregate. [Rubric §6, CQRS & Event-Driven] assesses whether cross-cutting behavior attaches declaratively rather than being wired inside handlers (ADR-014).
  • Walkthrough: three positional parameters with both interfaces on the declaration line (:15). CachePrefix (:18) returns $"{typeof(Session).FullName}:". The result type UpdateSessionResult shares the file (:24).
  • Why it's built this way: deriving the cache prefix from typeof(Session).FullName rather than a literal keeps the writer (this command) and the reader (the session query cache) agreed on one key namespace that a rename cannot desynchronize.
  • Caveats / not-in-source: two different caches are cleared on this path, and they are not duplicates. The decorator evicts the application-level query cache keyed by this CachePrefix; separately, SessionsController evicts the ASP.NET output cache by tag after a successful update (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:344, tags conference:sessions and conference). One is the handler's data cache, the other is the HTTP response cache.
  • Where it's used: constructed by SessionsController from the route id, the body and the decoded token (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:329-333) and handled by UpdateSessionHandler.

UpdateEventHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Update · MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:18 · Level 15 · class

  • What it is: the handler for UpdateEventCommand. It supplies the four hooks of the framework's load-mutate-save workflow and, when the time zone changes on an event that already has sessions, returns an advisory flag alongside the DTO (BR-131).
  • Depends on: MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload>, which it derives from (UpdateEventHandler.cs:6,22), IUnitOfWork (:5,19), EventDTOMapper (:2,20), Result (:7), the Event aggregate (:3), the Session aggregate used only for the existence probe (:4), IEntityReader<TEntity, TIdentifierType> (:5,50), UpdateEventResult (:22), and ILogger<T> from Microsoft.Extensions.Logging (:1,21).
  • Concept introduced: the template-method write handler. This class implements no HandleAsync. The base runs the whole workflow (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:271-309): resolve the aggregate's repository from the unit of work (:279), load it tracked (:280), fail with NotFound when it is gone (:281-282), stamp the caller's row version as the entity's original value so a concurrent edit loses at save time (:290-291), run the mutation (:293), honor a SkipSave short-circuit (:299-300), save (:302), then log and run the post-commit hook (:304-305). The payload flavor adds the answer-shaping step: HandleAsync creates the context, runs the core, and calls BuildResult on success (:395-406, abstract declaration at :418). A handler therefore contains only what is specific to its use case, which for this one is five members. [Rubric §2, Design Patterns] assesses whether recurring structure is captured as a pattern rather than copied: this is a textbook template method, and its existence is why every write handler in the module has the same failure semantics. [Rubric §1, SOLID]: the base is closed for modification and open through its hooks.
  • Concept introduced: carrying a derived value forward through the mutation context instead of handler state. The BR-131 check has to run before entity.Update(...), because afterwards the tracked entity already holds the new time zone and the old value is gone, but the flag is needed after the save, when the result is built. A field on the handler would be wrong: handlers are resolved per scope and shared within one, so per-command state must not live on the instance. The framework's answer is MutationContext, one typed bag created per command and threaded through every hook (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutationContext.cs:31-33). This handler writes under a private const key (:25, context.Set(...) at :57) and reads it back in BuildResult (context.GetOrDefault<bool>(...) at :86; the reader is MutationContext.cs:93). [Rubric §14, Testability]: the value travels with the command and dies with it, so two concurrent commands on one handler instance cannot see each other's warning.
  • Walkthrough: sealed partial class with primary-constructor DI (:18-22); partial because the log message is source-generated (:88-89). EntityId returns command.Id (:28). RowVersion returns command.RowVersion (:34), which is what opts this handler into the concurrency stamp; the comment above it says why (:30-31). MutateAsync (:37-73) is the whole use case: it first compares the stored time zone against the requested one with an ordinal string comparison (:45), and only when it actually moves does it pay for a query, resolving a read-only session repository (:50-51) and asking ExistsAsync(s => s.EventId == command.Id, ...) (:52-54). It stores the answer in the context (:57) and returns entity.Update(...) with all thirteen fields in declaration order (:59-72). LogMutated (:76-77) emits "Event {EventId} updated". BuildResult (:80-86) maps the entity through EventDTOMapper and pairs it with the flag.
  • Why it's built this way: three orderings are deliberate. The row version is stamped by the base before the mutation runs (MutateEntityHandlerBase.cs:291-292), so a stale caller loses at SaveChangesAsync regardless of which branch the BR-131 check took. The comparison happens before Update, because Update is what destroys the evidence. And every field goes into one Update call rather than property assignments, so the aggregate remains the only writer of its own state, re-runs its invariants through EventInvariants (MMCA.ADC.Conference.Domain/Events/Event.cs:271-274) and raises exactly one EventChanged per update (Event.cs:265), which the outbox picks up on the same save (ADR-003). [Rubric §12, Performance & Scalability]: the probe is conditional and uses ExistsAsync rather than materializing sessions, and it goes through GetReadRepository (:51) so nothing on that path can accidentally be tracked for write.
  • Caveats / not-in-source: the advisory is one-way. The handler reports that session times may now be misaligned but performs no re-timing and schedules no follow-up work, so acting on the warning (for example by re-running a Sessionize import) is left to the operator who receives the response header.
  • Where it's used: injected into EventsController as ICommandHandler<UpdateEventCommand, Result<UpdateEventResult>> (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:50) and invoked on PUT {id} (:231-233); the controller appends an X-Warning response header when the flag is set (:239-244), evicts the events output cache (:246) and returns only the DTO (:247).

UpdateSessionHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Update · MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:19 · Level 15 · class

  • What it is: the handler for UpdateSessionCommand. On the same load-mutate-save workflow as UpdateEventHandler, its mutation enforces BR-140 (the owning event is immutable), loads the parent event for the BR-130 room checks, runs the domain Update, and stashes the BR-86 date-range warning for the result envelope.
  • Depends on: MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload> (UpdateSessionHandler.cs:7,23), IUnitOfWork (:6,20), SessionDTOMapper (:2,21), SessionRoomScheduling (:3,66), Result and Error (:8), the Session (:5) and Event (:4) aggregates, IEntityReader<TEntity, TIdentifierType> (:6,55), UpdateSessionResult (:23) and ILogger<T> (:1,22).
  • Concept introduced: guard ordering inside a mutation. The four checks in MutateAsync are not interchangeable, and they run cheapest-and-most-certain first. BR-140 is a pure in-memory comparison of command.Request.EventId against the loaded entity's EventId, so it runs first and costs nothing (:45-52); a mismatch returns Error.UnprocessableEntity with the code Session.EventId.Immutable (:47-51) and no query is ever issued. Only then does the handler pay for the parent event, loaded with its Rooms collection and explicitly untracked because it is read, never written (:55-61); a missing parent is a NotFound failure sourced to this handler (:62-63). The room rules follow, because they need that event. The domain Update is last among the failing steps, and the BR-86 advisory is computed only after it succeeded (:98-100), since a rejected write should not produce a warning about times that were never stored. [Rubric §4, DDD] assesses where invariants live: a rule that needs a second aggregate cannot sit inside Session, so it sits here, in the use case that has a unit of work; a rule that needs only the session's own state (title, the end-after-start ordering) stays in the aggregate. [Rubric §12, Performance & Scalability]: each additional read is gated behind a check that already failed cheaply.
  • Walkthrough: sealed partial class with primary-constructor DI (:19-23), keyed context constant at :26. EntityId returns command.Id (:29); RowVersion returns command.RowVersion (:35) and is what makes the update conditional. MutateAsync (:38-103) runs the BR-140 guard (:45-52), resolves a read-only event repository and loads the parent with includes: [nameof(Event.Rooms)] and asTracking: false (:55-61), then delegates the room work to SessionRoomScheduling.ValidateRoomAssignmentAsync (:66-76), passing excludeSessionId: command.Id (:72) so the session does not collide with itself. It calls entity.Update(...) with all fourteen editable fields (:78-92), short-circuits on a domain failure (:94-95), and finally writes the BR-86 flag into the context from the private helper IsOutsideEventDateRange (:98-100, helper at :122-131), which compares DateOnly.FromDateTime(startsAt) against the event's StartDate and the end against EndDate. LogMutated (:106-107) emits "Session {SessionId} updated"; BuildResult (:110-116) maps the entity through SessionDTOMapper and pairs it with the flag read back from the context.
  • Why it's built this way: the room logic is a static helper rather than a method on this class because the create path needs exactly the same two checks, and both paths call it with the only difference expressed as an argument, excludeSessionId (MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:44-81). Inside it, a null RoomId short-circuits to success (:56-57), a room that is not in the parent event's Rooms fails with Session.RoomId.CrossEvent (:59-67), an unscheduled session skips the overlap probe (:69-70), and the overlap itself is an ExistsAsync over a SQL-translatable half-open-interval predicate (:74-76; predicate built at :93-106). Passing every field into one Update call keeps the aggregate the only writer of its own state and yields exactly one SessionChanged domain event per update (MMCA.ADC.Conference.Domain/Sessions/Session.cs:273), picked up by the outbox on the same save (ADR-003).
  • Caveats / not-in-source: the double-booking guard is soft by design and the type says so: the probe and the save are not one atomic step, so two concurrent organizer writes can both pass it, SQL Server has no range-exclusion constraint that could express the rule as an index, and the outcome is accepted because the endpoint is organizer-only and repairable (SessionRoomScheduling.cs:20-24). BR-86 is likewise advisory only: the flag reaches the caller as a header and nothing re-times the session.
  • Where it's used: injected into SessionsController as ICommandHandler<UpdateSessionCommand, Result<UpdateSessionResult>> (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:48) and invoked on PUT {id} (:330-332); the controller appends the X-Warning header when the flag is set (:338-341), evicts the session output-cache tags (:343) and returns only the DTO (:344).

SpeakerUpdateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequest.cs:8 · Level 1 · record

  • What it is: the body a client PUTs to edit an existing Speaker. Eleven init-only members covering the speaker's name, contact details, the profile prose an attendee reads, the four social/site URLs, and the organizer-curation flag.

  • Depends on: ISpeakerFieldsRequest from MMCA.ADC.Conference.Application.Speakers.Validation (SpeakerUpdateRequest.cs:1,8). Nothing else: every member is a BCL primitive, so the record has no domain type and no framework type in its shape.

  • Concept introduced: the update request defined by what it leaves out, twice over. Read this record against the update requests in the rest of the module and two absences stand out, both deliberate and both documented on the declaration itself.

    • No linked-user field. Speaker.LinkedUserId is editable state on the aggregate, but it is not in this contract (:6-7). The aggregate says the same thing from the other side: Speaker.Update assigns ten fields plus IsTopSpeaker and deliberately never touches the link, because only LinkUser and UnlinkUser carry the BR-208 uniqueness check and raise the events that keep Identity's User.LinkedSpeakerId in sync (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:176-182,223-233). A field removed from the wire contract is the strongest form of "this is not editable here": no validator has to reject it, and no reviewer has to remember the rule.
    • No concurrency token member. Unlike ConferenceCategoryUpdateRequest, this record does not implement IConcurrencyAware and carries no RowVersion. The token arrives in the If-Match header instead, decoded by the filter and read out with SupportsIfMatchAttribute.RequiredToken(HttpContext) at the action (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:334,349). That is the ADR-035 conditional-write path in its strict form: a request with no precondition never reaches the handler at all, so the token cannot be null by the time UpdateSpeakerCommand is built.

    [Rubric §9, API & Contract Design] assesses whether the wire contract expresses the operation's real boundaries: here the payload is the editable state and nothing more, with the precondition living in the transport where HTTP already defines its semantics. [Rubric §11, Security]: a field a caller cannot send is a field a crafted body cannot set, which is the same reasoning that keeps CallerIsOrganizer off this record and on the command (SpeakerUpdateApplier).

  • Walkthrough: public record class SpeakerUpdateRequest : ISpeakerFieldsRequest (:8). Two members are required and so cannot be omitted by a caller: FirstName (:11) and LastName (:14). The optional strings are Email (:17), Bio (:20), TagLine (:23), ProfilePicture (:26), TwitterHandle (:32), LinkedInUrl (:35), GitHubUrl (:38), and WebsiteUrl (:41). IsTopSpeaker (:29) is the one non-string member, a plain bool defaulting to false, and it is the field the applier ignores for a non-organizer caller. Every member is init-only, so the bound request is immutable for the rest of the request lifetime.

  • Why it's built this way: implementing ISpeakerFieldsRequest is what lets the create and the update request share one rule list. SpeakerFieldRules<T> is generic over T : ISpeakerFieldsRequest and includes six rule sets by interface member rather than by concrete property (MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:113-125), so the name, email and URL contracts are declared once for both entry paths. [Rubric §15, Best Practices & Code Quality]: adding a shared speaker field is an interface member plus one Include, not an edit in two validators that can drift apart.

  • Where it's used: bound from the body by SpeakersController on PUT {id} (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:340), validated by SpeakerUpdateRequestValidator, wrapped into UpdateSpeakerCommand with the role flag and the header token (:351), and consumed field by field by SpeakerUpdateApplier.

SponsorUpdateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequest.cs:11 · Level 1 · record

  • What it is: the body a client PUTs to edit an existing Sponsor or exhibitor: the display identity, the tier, the marketing links, the display order, and the expo-booth pair.
  • Depends on: ISponsorFieldsRequest from MMCA.ADC.Conference.Application.Sponsors.Validation and the SponsorTier enum from MMCA.ADC.Conference.Shared.Sponsors (SponsorUpdateRequest.cs:1-2,11,17). Everything else is a BCL primitive.
  • Concept introduced: none new; it is the same shape as SpeakerUpdateRequest, including the deliberate omission and the header-only concurrency token. The omission here is the owning event: moving a sponsor between conference editions is a create plus a delete, so a mistyped EventId cannot silently relocate bought placement (:7-10), and the interface repeats the reasoning so the create side keeps the field while the update side never sees it (MMCA.ADC.Conference.Application/Sponsors/Validation/ISponsorFieldsRequest.cs:8-11). [Rubric §9, API & Contract Design] assesses whether an operation's contract expresses only what that operation may change. [Rubric §4, Domain-Driven Design]: an event's sponsor roster is part of that event's identity, so re-parenting is a different intent rather than a field edit.
  • Walkthrough: public record class SponsorUpdateRequest : ISponsorFieldsRequest (:11). Name is the single required member (:14). Tier (:17) is the SponsorTier enum, Sort (:35) the integer display order within the tier, and IsExhibitor (:38) the boolean that pairs with the optional BoothNumber (:41). The remaining optional strings are LogoUrl (:20), Description (:23), WebsiteUrl (:26), LinkedInUrl (:29), and TwitterHandle (:32). All members are init-only.
  • Why it's built this way: because the record adds nothing beyond the interface's fields, the sponsor update rides the framework's generic command with no module-specific command type at all: the controller constructs UpdateEntityCommand<Sponsor, SponsorUpdateRequest, SponsorIdentifierType> directly (MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:195). Compare UpdateSpeakerCommand, which exists only because the speaker update needs one more piece of server-decided state. [Rubric §5, Vertical Slice]: the slice contains exactly what it needs, a request record, a validator and an applier, and borrows the rest.
  • Caveats / not-in-source: Tier carries no IsInEnum rule in SponsorUpdateRequestValidator; SponsorFieldRules<T> includes no tier rule (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:145-152). An out-of-range integer bound into the enum-typed property is therefore not rejected at the validator.
  • Where it's used: bound by SponsorsController on PUT {id} (MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:189), validated by SponsorUpdateRequestValidator, and applied by SponsorUpdateApplier under the framework's generic update handler, registered with one AddEntityCrud call (MMCA.ADC.Conference.Application/DependencyInjection.cs:145).

SpeakerDeletedHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.DomainEventHandlers · MMCA.ADC.Conference.Application/Speakers/DomainEventHandlers/SpeakerDeletedHandler.cs:20 · Level 4 · class

  • What it is: the in-module reaction to a speaker soft-delete. It listens to every SpeakerChanged domain event, ignores all but the Deleted ones, logs the deletion, and publishes the SpeakerUnlinkedFromUser integration event so the Identity module can clear the other half of the link.

  • Depends on: IDomainEventHandler<in TDomainEvent> closed over SpeakerChanged (SpeakerDeletedHandler.cs:3,5,22), IEventBus resolved per invocation (:5,41), SpeakerUnlinkedFromUser (:4,43), DomainEntityState (:6,29), and two externals: IServiceScopeFactory (Microsoft.Extensions.DependencyInjection, :1,21) and ILogger<T> plus [LoggerMessage] (Microsoft.Extensions.Logging, :2,22,48).

  • Concept introduced: the domain-event handler as the boundary between two modules' data. A speaker and a user each hold a pointer to the other: Speaker.LinkedUserId in Conference and User.LinkedSpeakerId in Identity. They live in different modules and, under database-per-service (ADR-006), different databases, so one transaction cannot clear both. The aggregate clears its own side inside Speaker.Delete() and captures the old value first, precisely so the handler can still see it after the field is null (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:253-263). This handler then converts that in-process fact into a published contract, and Identity's SpeakerUnlinkedFromUserHandler applies it on its own side and in its own transaction. Three mechanics are worth internalizing:

    • State gating. One event type covers added, updated and deleted, so the handler's first real statement is a guard that returns for anything other than DomainEntityState.Deleted (:29-30). Subscribing to a lifecycle event and filtering is the module's convention; there is no SpeakerDeleted type.
    • Singleton handler, per-invocation scope. Domain event handlers are registered with singleton lifetime by the module scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:186-191). A singleton cannot hold a scoped IEventBus, so the handler injects IServiceScopeFactory and opens its own await using scope for the publish (:40-41). The comment on the call site says exactly this (:36-37).
    • Eventual consistency, stated as such. The class documentation records that this replaced a direct call into an Identity service and that the cleanup is now asynchronous through the broker, or in-process through the outbox in monolith mode (:14-18). That dual dispatch is ADR-003.

    [Rubric §6, CQRS & Event-Driven] assesses whether cross-boundary effects travel as events rather than calls: this handler is the conversion point from a domain event to an integration event. [Rubric §7, Microservices Readiness]: because Identity is reached only by a published contract, extracting Conference to its own process changes the transport and nothing in this file. [Rubric §13, Observability & Operability]: the [LoggerMessage] partial (:48-49) is source-generated and allocation-free, and it records the previous linked user id, which is the one value that is gone from the database after the delete.

  • Walkthrough: the class is sealed partial with a primary constructor taking the scope factory and the logger (:20-22); partial because [LoggerMessage] generates the log method body into the other half. HandleAsync (:25) null-guards the event (:27), applies the state gate (:29-30), and emits LogSpeakerDeleted with the speaker id, the full name and the previous linked user id (:32). It publishes only when there was a link to clear (:38): no linked user means no other module holds a stale pointer, so no event is warranted. The publish itself opens an async scope, resolves IEventBus from it, and awaits PublishAsync(new SpeakerUnlinkedFromUser(previousLinkedUserId, speakerId), ct) with ConfigureAwait(false) (:40-44). The event carries a stable wire name through [EventName("Conference.SpeakerUnlinkedFromUser.v1")] (MMCA.ADC.Conference.Shared/Speakers/IntegrationEvents/SpeakerUnlinkedFromUser.cs:18-22).

  • Why it's built this way: the same integration event is raised by the explicit unlink command as well as by this delete cascade, which is why it lives in MMCA.ADC.Conference.Shared rather than in the Application project (SpeakerUnlinkedFromUser.cs:6-9). One contract, two producers, one consumer. Putting the publish in a domain-event handler rather than in the delete handler also means any future path that soft-deletes a speaker inherits the cleanup automatically, since it is the aggregate that raises the event.

  • Caveats / not-in-source: the handler does not await Identity's reaction and has no compensating action if that side fails; the retry and dead-letter behavior belongs to the transport and the outbox (see group 04), not to this file.

  • Where it's used: never constructed by name. It is discovered by ScanModuleApplicationServices<ClassReference>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:133) and invoked by DomainEventDispatcher as part of the save that soft-deleted the speaker.

AddCategoryItemCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommand.cs:14 · Level 7 · record

  • What it is: the write intent for adding one CategoryItem (a "Beginner", a "Track A") to an existing Category. Four positional members and one computed cache prefix.
  • Depends on: ICacheInvalidating from MMCA.Common.Application.UseCases (AddCategoryItemCommand.cs:2,18) and the Category domain type, used only for its FullName in the prefix (:1,21). ConferenceCategoryIdentifierType and CategoryItemIdentifierType are the module's identifier aliases.
  • Concept introduced: the command that is its own request, and the optional client-supplied identity. Most write slices in this module are a pair, a request record plus a command that wraps it, which is what wires the automatic IValidator<TRequest> bridge (ICommandWithRequest<out TRequest>). This one is flat: the API's own AddCategoryItemRequest is unpacked member by member into the command at the controller (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:135-139), so the command is the validated object and AddCategoryItemCommandValidator targets the command type directly. The second point of interest is CategoryItemId (:16), a nullable identifier on a create: null means "let the database generate the key", non-null means "use this Sessionize-assigned id" (:11). That is what lets an import replay upstream keys and a hand-created item take a local one, through one code path. [Rubric §6, CQRS & Event-Driven] assesses whether writes are explicit intents carrying everything the handler needs; [Rubric §8, Data Architecture]: an externally-assigned primary key is a real modelling decision, and making it an option on the command keeps the import from needing a parallel write path.
  • Walkthrough: sealed record AddCategoryItemCommand(ConferenceCategoryIdentifierType CategoryId, CategoryItemIdentifierType? CategoryItemId, string Name, int Sort) : ICacheInvalidating (:14-18). CategoryId is the parent to load, Name and Sort are the child's two editable fields. CachePrefix is an expression-bodied property returning $"{typeof(Category).FullName}:" (:21), the key namespace the CachingCommandDecorator<TCommand, TResult> wipes after a successful handle. The prefix names the parent aggregate, not the child, because a category's cached read includes its items.
  • Why it's built this way: deriving the prefix from typeof(Category).FullName rather than a literal keeps the writer and the cached reader agreed on one key namespace that a rename cannot desynchronize. The command implements no ITransactional marker: the handler writes one aggregate and saves once, so the ambient save boundary suffices (ADR-014).
  • Where it's used: constructed by CategoryItemsController on POST against the injected ICommandHandler<AddCategoryItemCommand, Result<CategoryItemDTO>> (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:65,128-140), and handled by AddCategoryItemHandler. That action carries [Idempotent] (:129), so a retried request with the same Idempotency-Key replays the first response instead of adding a second row (IdempotentAttribute).

CategoryItemDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.DTOs · MMCA.ADC.Conference.Application/Categories/DTOs/CategoryItemDTOMapper.cs:12 · Level 7 · class

  • What it is: the mapper that turns a CategoryItem entity into a CategoryItemDTO, one at a time or in bulk.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over the three category-item types (CategoryItemDTOMapper.cs:1-3,12-13), and the Riok.Mapperly.Abstractions [Mapper] attribute (:4,11).
  • Concept introduced: the source-generated DTO mapper. The body of MapToDTO is not in this file. [Mapper] on a partial class tells the Mapperly source generator to emit the property-by-property assignment at compile time, and the class declares only the signature: public partial CategoryItemDTO MapToDTO(CategoryItem entity) (:16). Three consequences follow, and they are the whole reason the codebase prefers this over a runtime convention mapper such as AutoMapper (ADR-001). A property rename breaks the build rather than silently producing a null field. The generated code is plain, steppable C# with no reflection or expression compilation at request time. And a stack trace points at a specific line in a specific mapper. The mapping itself is by name: CategoryItemDTO declares Id, Name, Sort and CategoryId (MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:19,22,25,28), all of which exist on the entity. [Rubric §15, Best Practices & Code Quality] assesses whether repetitive mechanical code is generated rather than hand-maintained; [Rubric §12, Performance & Scalability]: compile-time mapping removes per-request reflection from every read path this mapper serves.
  • Walkthrough: [Mapper] (:11) then public sealed partial class CategoryItemDTOMapper : IEntityDTOMapper<CategoryItem, CategoryItemDTO, CategoryItemIdentifierType> (:12-13). One generated member, MapToDTO (:16). MapToDTOs is written out by hand as a null-guard plus a collection expression over Select(MapToDTO) (:19-23), which is the same body the interface already supplies as a default implementation (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:27-32); re-declaring it makes the batch method visible on the concrete type rather than only through the interface. ADR-001 notes this re-declaration as the prevailing habit across the 31 mappers in Store and ADC.
  • Where it's used: injected by name into AddCategoryItemHandler (MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemHandler.cs:17) and into UpdateCategoryItemHandler; nested as a child mapper by ConferenceCategoryDTOMapper (ConferenceCategoryDTOMapper.cs:14,18). It is registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133) both as itself and by its interface, which is what lets a framework generic such as EntityQueryService<TEntity, TEntityDTO, TIdentifierType> resolve it as IEntityDTOMapper<...> without naming it.

AddCategoryItemCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:7 · Level 8 · class

  • What it is: the FluentValidation validator for AddCategoryItemCommand, composing two reusable rule sets and adding nothing of its own.
  • Depends on: AbstractValidator<T> (FluentValidation, AddCategoryItemCommandValidator.cs:1,7), AddCategoryItemCommand, and two rule sets from MMCA.ADC.Conference.Application.Categories.Validation (:2,11-12): CategoryItemNameRules<T> and CategoryItemSortRules<T>.
  • Concept introduced: validating the command when there is no request record to validate. Everywhere else in this module the validator targets the request DTO and the framework bridges it to the command through CommandRequestValidator<TCommand, TRequest>. AddCategoryItemCommand carries no request, so the bridge has nothing to bridge and the validator names the command type directly (:7). Nothing else changes: the ValidatingCommandDecorator<TCommand, TResult> resolves IValidator<AddCategoryItemCommand> exactly as it would resolve any other, because the decorator keys on the command type either way. This is the clean illustration that the bridge is a convenience for the two-record shape, not a requirement of the pipeline. [Rubric §24, Forms/Validation/UX Safety] assesses whether every inbound field has a declared contract; [Rubric §1, SOLID]: this class composes and does nothing else, so a name-length change is found in one rule set rather than in each validator that guards a name.
  • Walkthrough: sealed class AddCategoryItemCommandValidator : AbstractValidator<AddCategoryItemCommand> (:7). The constructor (:9-13) is two Include calls with property selectors: CategoryItemNameRules<AddCategoryItemCommand>(p => p.Name) (:11) contributes NotEmpty plus MaximumLength(CategoryInvariants.CategoryItemNameMaxLength) with the stable codes CategoryItem.Name.Required and CategoryItem.Name.MaxLength (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:31-34), and CategoryItemSortRules<AddCategoryItemCommand>(p => p.Sort) (:12) is a thin subclass of the shared NonNegativeIntRules<T> carrying the code CategoryItem.Sort.Negative (ConferenceCategoryValidationRules.cs:41-46). CategoryId and CategoryItemId carry no rule: a missing parent is a NotFound the handler reports after a load, not a shape error.
  • Why it's built this way: the name's maximum length is not a literal here. The rule set reads CategoryInvariants.CategoryItemNameMaxLength, the same constant the domain guard and the EF column width use (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Categories/CategoryItemConfiguration.cs:18-20), so the validator message, the aggregate's own check and the database agree on one number. [Rubric §15, Best Practices & Code Quality]: widening the field is a one-constant change.
  • Where it's used: discovered by assembly scanning (MMCA.ADC.Conference.Application/DependencyInjection.cs:133) and executed by the ValidatingCommandDecorator<TCommand, TResult> ahead of AddCategoryItemHandler.

ConferenceCategoryDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.DTOs · MMCA.ADC.Conference.Application/Categories/DTOs/ConferenceCategoryDTOMapper.cs:13 · Level 8 · class

  • What it is: the mapper for the Category aggregate, producing a ConferenceCategoryDTO with its child items mapped through CategoryItemDTOMapper.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over the category types (ConferenceCategoryDTOMapper.cs:1-3,15), CategoryItemDTOMapper injected by concrete type (:14,18), and Mapperly's [Mapper] and [UseMapper] attributes (:4,12,17).
  • Concept introduced: nested mapper composition. A generated mapper knows how to copy scalar properties by name, but Category.CategoryItems is a collection of entities and the DTO wants a collection of DTOs. [UseMapper] on a private field holding another mapper (:17-18) tells the generator to route any member it cannot map directly through that mapper, so the emitted MapToDTO projects each CategoryItem through CategoryItemDTOMapper rather than the parent mapper duplicating the child's field list. Composition rather than a second copy of the same mapping is what keeps a change to CategoryItemDTO from needing an edit in two files. [Rubric §15, Best Practices & Code Quality] assesses whether one fact is expressed once; [Rubric §2, Design Patterns]: this is plain constructor composition, and it is why the child mapper is registered as its own concrete type by the module scan and not only by its interface.
  • Walkthrough: [Mapper] (:12), then public sealed partial class ConferenceCategoryDTOMapper(CategoryItemDTOMapper categoryItemDTOMapper) : IEntityDTOMapper<Category, ConferenceCategoryDTO, ConferenceCategoryIdentifierType> (:13-15). The primary-constructor parameter is captured into a [UseMapper] private readonly field (:17-18), which is the form the generator looks for. MapToDTO is the generated partial (:21); MapToDTOs is the hand-written null-guard plus projection (:24-28). Note the naming asymmetry the mapper straddles: the domain type is Category, the DTO is ConferenceCategoryDTO, and the identifier alias is ConferenceCategoryIdentifierType, so this file is the place where the two vocabularies meet. The target DTO also carries a RowVersion (MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:26), which is what lets a client echo the token back as an If-Match precondition on the next update (ADR-035).
  • Caveats / not-in-source: whether the CategoryItems collection is populated in any given response depends on what the caller loaded, not on the mapper. A category read without the child include maps to an empty CategoryItems list.
  • Where it's used: injected into CreateConferenceCategoryHandler (MMCA.ADC.Conference.Application/Categories/UseCases/Create/CreateConferenceCategoryHandler.cs:18), and resolved as IEntityDTOMapper<Category, ConferenceCategoryDTO, ConferenceCategoryIdentifierType> by the framework's generic write and read paths registered through AddEntityCrud<Category, ...> (MMCA.ADC.Conference.Application/DependencyInjection.cs:143).

UpdateSpeakerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerCommand.cs:20 · Level 8 · record

  • What it is: the write intent for updating a Speaker. It is the framework's generic update command plus one extra member: whether the caller holds the Organizer role.
  • Depends on: UpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType> from MMCA.Common.Application.UseCases as its base record (UpdateSpeakerCommand.cs:2,25), SpeakerUpdateRequest (:22), and the Speaker type, reached only through the base's cache prefix (:1).
  • Concept introduced: deriving from the generic command to carry server-decided state. The framework's base command is deliberately not sealed, and its own documentation names this exact case: an update often carries a flag the server decided rather than the caller (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityCommand.cs:28-37). CallerIsOrganizer (:23) is bound at the API edge from the role claim, never from the body (:14-16), and BR-214 is the rule that makes it matter: a speaker may edit their own profile, but only an organizer may set the curation flag IsTopSpeaker. Putting that bit on the command rather than the request is the whole design: a request member is caller-controlled, a command member built by the controller is not. Deriving buys three things for free, all stated on the base: the inherited Id, Request and RowVersion; the inherited CachePrefix, defaulting to typeof(Speaker).FullName + ":" (UpdateEntityCommand.cs:65); and the ICommandWithRequest<TRequest> implementation that keeps the IValidator<SpeakerUpdateRequest> bridge running so no UpdateSpeakerCommandValidator has to exist (:9-10). [Rubric §11, Security] assesses whether a privilege decision is made where the privilege is known: the role is read from the authenticated principal at the controller, and the applier consumes a boolean it cannot influence. [Rubric §6, CQRS & Event-Driven]: the command remains the pipeline's single dispatch key, so all four decorators behave identically for the derived type.
  • Walkthrough: sealed record UpdateSpeakerCommand(SpeakerIdentifierType Id, SpeakerUpdateRequest Request, bool CallerIsOrganizer, byte[] RowVersion) : UpdateEntityCommand<Speaker, SpeakerUpdateRequest, SpeakerIdentifierType>(Id, Request, RowVersion) (:20-25). Three of the four positional members are forwarded straight to the base constructor; only CallerIsOrganizer is new. RowVersion is a non-nullable byte[] because the endpoint is conditional: SupportsIfMatchAttribute answers 428 for a missing If-Match and 412 for a stale one before the action body runs (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:327-330,334), so a null token cannot reach the command. There is no body: every member is either inherited or positional.
  • Why it's built this way: because the module declares its own command type, the framework registration is AddEntityUpdate rather than the plain AddEntityCrud used for sponsors and activities (MMCA.ADC.Conference.Application/DependencyInjection.cs:153, and the block comment explaining the choice at :144-149). That call closes the framework's derived-command handler over UpdateSpeakerCommand with TryAdd and registers the command-to-request validator bridge (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:444-459). TryAdd is the load-bearing detail: the module's own UpdateSpeakerHandler was already registered by the assembly scan, so it keeps the verb and the framework registration only completes the bridge.
  • Where it's used: constructed by SpeakersController after its BR-214 authorization check, with named arguments for the two non-obvious members (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:343-352), against the injected ICommandHandler<UpdateSpeakerCommand, Result<SpeakerDTO>> (:49); consumed by SpeakerUpdateApplier and handled by UpdateSpeakerHandler.

AddCategoryItemHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.AddCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemHandler.cs:15 · Level 9 · class

  • What it is: the handler for AddCategoryItemCommand. It supplies five small overrides to the framework's add-a-child workflow: which parent to load, what to eager-load, which aggregate method to call, how to map the new child, and what to log.
  • Depends on: AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO> (AddCategoryItemHandler.cs:6,19), IUnitOfWork forwarded to the base (:5,16), CategoryItemDTOMapper (:2,17), Result (:7,29), the Category and CategoryItem domain types (:3), CategoryItemDTO (:4), and ILogger<T> (:1,18).
  • Concept introduced: the template-method handler. The base class owns the entire workflow and calls back into the subclass for the parts only the module knows: get the repository, load the parent by id with the declared includes and change tracking, return NotFound tagged with the handler name and the parent type when it is gone, call Apply, short-circuit on failure, save once, log, run the post-commit hook, and return the mapped child (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:102-127). The subclass writes no try, no null check and no save. Two hooks are worth calling out because their defaults encode a policy. AsTracking defaults to true, since a no-tracking load would turn the add into a silent no-op (ChildEntityHandlerBase.cs:50). And Includes is abstract, not virtual (:55), which forces every subclass to make an explicit decision about loading the child collection rather than inheriting a quiet default. [Rubric §2, Design Patterns] assesses whether recurring workflows are factored behind a stable extension point; [Rubric §4, Domain-Driven Design]: the handler assigns nothing itself, it calls the aggregate method that owns the invariant and raises CategoryItemChanged (MMCA.ADC.Conference.Domain/Categories/Category.cs:142-144).
  • Walkthrough: sealed partial class AddCategoryItemHandler(IUnitOfWork unitOfWork, CategoryItemDTOMapper dtoMapper, ILogger<AddCategoryItemHandler> logger) : AddChildEntityHandlerBase<AddCategoryItemCommand, Category, ConferenceCategoryIdentifierType, CategoryItem, CategoryItemDTO>(unitOfWork) (:15-19); only the unit of work is forwarded to the base, the mapper and logger stay local. The five overrides, in file order: Includes => [] with a comment stating that nothing is eager-loaded on this path (:21-23); ParentId returns command.CategoryId (:26); Apply is one line into the aggregate, parent.AddCategoryItem(command.CategoryItemId, command.Name, command.Sort) (:29-30); MapChild is one line into the mapper (:33); LogAdded forwards to the source-generated partial with the item name and the category id (:36-40).
  • Caveats / not-in-source: the empty Includes and the aggregate's uniqueness check are in tension, and it is worth knowing which guard actually holds. Category.AddCategoryItem opens with BR-138, a case-insensitive uniqueness check against the in-memory _categoryItems collection (MMCA.ADC.Conference.Domain/Categories/Category.cs:131-134), and the base class documents that loading the child collection is what makes such a check meaningful (ChildEntityHandlerBase.cs:15-19,106-107). With no include declared and no AutoInclude configured on the navigation, that collection is not eager-loaded, so a cold add sees an empty list and the BR-138 check passes. The guard that does hold in that case is the database: a unique index on (CategoryId, Name) (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Categories/CategoryItemConfiguration.cs:30-31). The practical difference is the shape of the answer, a clean domain error versus a unique-index violation surfaced by the middleware. Whether any given request has the collection loaded through change-tracker fix-up from an earlier read in the same scope is not determinable from source.
  • Where it's used: resolved as ICommandHandler<AddCategoryItemCommand, Result<CategoryItemDTO>> by CategoryItemsController (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:65), behind the standard decorator pipeline, and registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133).

SpeakerUpdateApplier

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateApplier.cs:13 · Level 9 · class

  • What it is: the one place that knows how an UpdateSpeakerCommand becomes a call to Speaker.Update, including the BR-214 rule that a self-editing speaker cannot change the organizer-only curation flag.
  • Depends on: IEntityUpdateCommandApplier<TEntity, TUpdateRequest, TIdentifierType, in TCommand> (SpeakerUpdateApplier.cs:2,14), the Speaker aggregate (:1), UpdateSpeakerCommand and SpeakerUpdateRequest (:14), Result and MutationContext (:3,17,20).
  • Concept introduced: the command-aware applier, and the privilege-gated field. The framework has two applier contracts. IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType> receives only the request, which is right when the body is the whole truth (see SponsorUpdateApplier). This one receives the whole command, which is what an update needs when it depends on state the request does not and must not carry (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/IEntityUpdateCommandApplier.cs:14-21). The rule it implements is a single expression: var isTopSpeaker = command.CallerIsOrganizer ? command.Request.IsTopSpeaker : entity.IsTopSpeaker (:32). A self-editing speaker's submitted value is discarded in favour of the stored one, so a crafted body cannot feature its own speaker on the site. The comment above it also records the second half of the story: LinkedUserId is not in this path at all, because the governed /link and /unlink endpoints carry the BR-208 uniqueness check and raise the events that keep Identity in sync (:26-31). [Rubric §11, Security] assesses whether authorization decisions constrain the write and not merely the route: the role check at the controller decides who may call, and this line decides what a caller may change. [Rubric §1, SOLID]: the applier is the only type that names speaker fields, so the generic handler above it stays field-agnostic. [Rubric §3, Clean Architecture]: the applier translates, the aggregate validates.
  • Walkthrough: sealed class SpeakerUpdateApplier : IEntityUpdateCommandApplier<Speaker, SpeakerUpdateRequest, SpeakerIdentifierType, UpdateSpeakerCommand> (:13-14). ApplyAsync takes the tracked entity, the command, the MutationContext and a token (:17-22), null-guards entity and command (:23-24), computes the gated flag (:32), and returns Task.FromResult(entity.Update(...)) with the ten request fields in the aggregate's parameter order plus the computed flag (:34-45). It is synchronous work wrapped in a completed task, because the mutation is pure in-memory state on an already-loaded aggregate; nothing here touches the database. The MutationContext parameter is accepted and unused: this update neither needs to carry a derived value out to the post-save hooks nor to call SkipSave.
  • Why it's built this way: returning the aggregate's own Result unchanged is the contract. Speaker.Update first parses the optional email into an Email value object and returns that failure if the address is malformed (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:208-215), then combines the two name invariants (:217-221), and only then assigns and raises SpeakerChanged (:223-235). A refusal therefore reaches the caller as domain errors and the generic handler never saves, because the instance handed to the applier is the tracked one and a failure must leave it untouched (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/IEntityUpdateCommandApplier.cs:28-32).
  • Where it's used: injected by concrete type into UpdateSpeakerHandler (MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:19), which passes it to the framework base as the IEntityUpdateCommandApplier<...> the workflow calls. It is picked up by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133), which registers it as itself as well as by its interfaces.

SpeakerUpdateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/SpeakerUpdateRequestValidator.cs:7 · Level 9 · class

  • What it is: the validator for SpeakerUpdateRequest. Its entire body is one Include of the shared speaker field rules.
  • Depends on: AbstractValidator<T> (FluentValidation, SpeakerUpdateRequestValidator.cs:1,7), SpeakerUpdateRequest, and SpeakerFieldRules<T> from MMCA.ADC.Conference.Application.Speakers.Validation (:2,10).
  • Concept introduced: none new; this is Include composition (taught in group 06) in its most compact form, but note what it includes. SpeakerFieldRules<T> is constrained where T : ISpeakerFieldsRequest and selects its properties through the interface, so it takes no selector arguments at all (MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:113-125). Compare AddCategoryItemCommandValidator, which passes a lambda per rule set because its command implements no such interface. Both styles are in the module; the interface-typed one is what an aggregate uses once its create and update requests validate identically. [Rubric §24, Forms/Validation/UX Safety] assesses whether input constraints are single-sourced across entry paths: the create-side and update-side speaker validators run the identical six rules, and neither file lists them. [Rubric §11, Security]: three of those six rules are the URL guards that reject a non-http(s) absolute URL, which matters because the speaker pages render the stored value straight into a link (SpeakerValidationRules.cs:51-54).
  • Walkthrough: sealed class SpeakerUpdateRequestValidator : AbstractValidator<SpeakerUpdateRequest> (:7) with an expression-bodied constructor (:9-10) that includes new SpeakerFieldRules<SpeakerUpdateRequest>(). That rule set folds in six rule sets in turn (SpeakerValidationRules.cs:119-124): first and last name through RequiredStringRules<T> bounded by SpeakerInvariants (:12-28), the optional email through EmailRules<T> under a non-blank When guard (:38-48), and LinkedIn, GitHub and website URLs through AbsoluteUrlRules<T> under the same guard (:57-105). The When wrapper is what makes clearing an optional field always legal. Fields with no rule here: Bio, TagLine, ProfilePicture, TwitterHandle and IsTopSpeaker, the last of which is governed by the applier rather than by validation.
  • Where it's used: registered by assembly scanning and reached through UpdateSpeakerCommand's inherited ICommandWithRequest<out TRequest> implementation, which the framework bridges with a CommandRequestValidator<TCommand, TRequest> registered by AddEntityUpdate (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:456). The ValidatingCommandDecorator<TCommand, TResult> runs it before UpdateSpeakerHandler.

SponsorUpdateApplier

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateApplier.cs:12 · Level 9 · class

  • What it is: the one place that knows how a SponsorUpdateRequest becomes a call to Sponsor.Update.
  • Depends on: IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType> (SponsorUpdateApplier.cs:2,13), the Sponsor aggregate (:1), SponsorUpdateRequest (:13), and Result (:3,16).
  • Concept introduced: none new; it is the request-only twin of SpeakerUpdateApplier and the more common of the two shapes. It is worth reading precisely because there is nothing to decide: the body is the whole truth for a sponsor edit, so the applier takes the request rather than a command, and the module needs no command type of its own. The contract's own documentation frames the pair: the create mapper owns "request to a new aggregate", the applier owns "request onto an existing aggregate", and putting the mutation behind this interface is what lets the framework ship one generic update handler (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:62-69). [Rubric §3, Clean Architecture] assesses whether each layer holds only what it is entitled to hold: the Application layer translates a DTO into an aggregate call, and every business check stays in Domain.
  • Walkthrough: sealed class SponsorUpdateApplier : IEntityUpdateApplier<Sponsor, SponsorUpdateRequest, SponsorIdentifierType> (:12-13). ApplyAsync null-guards the entity and the request (:18-19) and returns Task.FromResult(entity.Update(...)) with all ten request members in the aggregate's parameter order (:21-31). Sponsor.Update combines three invariant checks (name, logo URL, booth number) and returns early on failure, then assigns the ten fields and raises SponsorChanged with DomainEntityState.Updated (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:165-183). No MutationContext parameter exists on this interface, so this applier has no way to skip the save or to hand a value to a post-save hook, and needs neither.
  • Why it's built this way: keeping the applier free of any command type is what allows the whole sponsor write side to be registered in a single line, AddEntityCrud<Sponsor, SponsorDTO, SponsorIdentifierType, SponsorCreateRequest, SponsorUpdateRequest>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:145), with the framework supplying the update and delete handlers and the module supplying only this applier and the create side. [Rubric §15, Best Practices & Code Quality]: the sponsor update slice is three small files and one registration line.
  • Where it's used: resolved from the container as IEntityUpdateApplier<Sponsor, SponsorUpdateRequest, SponsorIdentifierType> by the framework's generic UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>, which the PUT action reaches through UpdateEntityCommand<Sponsor, SponsorUpdateRequest, SponsorIdentifierType> (MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:43,195).

SponsorUpdateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Update · MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:7 · Level 9 · class

  • What it is: the validator for SponsorUpdateRequest: one Include of the shared sponsor field rules.
  • Depends on: AbstractValidator<T> (FluentValidation, SponsorUpdateRequestValidator.cs:1,7), SponsorUpdateRequest, and SponsorFieldRules<T> from MMCA.ADC.Conference.Application.Sponsors.Validation (:2,10).
  • Concept introduced: none new; it is the interface-typed composition taught at SpeakerUpdateRequestValidator, one aggregate over. Reading the two files side by side is the fastest way to see the module's uniformity: same one-line expression-bodied constructor, same where T : IXFieldsRequest rule set, different aggregate. The difference is only in the rule list the shared set folds in. [Rubric §5, Vertical Slice] assesses whether a feature's types sit together and follow one recognizable shape, so that a new slice is a copy of a known pattern rather than an act of invention.
  • Walkthrough: sealed class SponsorUpdateRequestValidator : AbstractValidator<SponsorUpdateRequest> (:7); the constructor (:9-10) includes new SponsorFieldRules<SponsorUpdateRequest>(). That rule set includes eight rule sets, covering name, sort, logo URL, description, website URL, LinkedIn URL, Twitter handle and booth number (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:139-154). The two members with no rule are Tier and IsExhibitor, which is why the tier caveat noted at SponsorUpdateRequest is a validator-level absence rather than an oversight in this file.
  • Where it's used: registered by assembly scanning, bridged to UpdateEntityCommand<Sponsor, SponsorUpdateRequest, SponsorIdentifierType> by the CommandRequestValidator<TCommand, TRequest> that AddEntityCrud registers, and executed by the ValidatingCommandDecorator<TCommand, TResult> before the framework's generic update handler runs.

UpdateSpeakerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Update · MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:17 · Level 11 · class

  • What it is: the handler for UpdateSpeakerCommand. It inherits the entire load-stamp-apply-save workflow from the framework and contributes exactly two things: the handler name a NotFound failure reports, and the module's structured log line.
  • Depends on: UpdateEntityCommandHandler<TCommand, TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> (UpdateSpeakerHandler.cs:6,22), IUnitOfWork (:5,18), SpeakerUpdateApplier (:19), SpeakerDTOMapper (:2,20), the Speaker aggregate (:3), SpeakerDTO (:4), and ILogger<T> with [LoggerMessage] (:1,21,34).
  • Concept introduced: subclassing a generic handler for vocabulary alone. There is no HandleAsync in this file. The base's shared machinery does all of it, and it is worth tracing once, because every write handler in the module ultimately runs it (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:280-308): get the typed repository from the unit of work (:279), load the aggregate tracked (:280), return Error.NotFound tagged with HandlerName and the entity type when it is gone (:281-282), stamp the caller's token with repository.SetOriginalRowVersion(entity, rowVersion) when one is present (:290-291), run the mutation (:293), short-circuit on refusal (:294-295), honour a SkipSave (:299-300), save once (:302), then log and run the post-save hook (:304-305). The DTO-returning subclass maps the saved aggregate through the registered mapper on the way out (:352-359). The concurrency stamp is the non-obvious line: without it EF Core would compare the row against the version it loaded a moment ago and always succeed, whereas stamping the client's last-seen token turns the UPDATE predicate into "has anyone written this row since the client read it?" and raises a concurrency exception when they have, surfaced as 412 rather than silent last-write-wins (ADR-035, and the in-code note at :284-289). The derived-command flavour then hands the whole command to the applier rather than only the request (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:218-223). [Rubric §15, Best Practices & Code Quality] assesses whether shared workflow lives in one place: a change to the update workflow is one edit in MMCA.Common, not one per aggregate. [Rubric §13, Observability & Operability]: the two overrides are precisely the operator-facing parts, the name in an error and the message in a log.
  • Walkthrough: sealed partial class UpdateSpeakerHandler(IUnitOfWork unitOfWork, SpeakerUpdateApplier updateApplier, SpeakerDTOMapper dtoMapper, ILogger<UpdateSpeakerHandler> logger) : UpdateEntityCommandHandler<UpdateSpeakerCommand, Speaker, SpeakerDTO, SpeakerIdentifierType, SpeakerUpdateRequest>(unitOfWork, updateApplier, dtoMapper) (:17-25). Three of the four constructor parameters are forwarded to the base; only the logger stays local. HandlerName => nameof(UpdateSpeakerHandler) (:28) overrides the base's default, which would otherwise read as the open generic plus the command name (UpdateEntityHandler.cs:199), so a NotFound failure reads as it always has. LogMutated forwards to the source-generated partial with the speaker id (:31-32), declared at [LoggerMessage(Level = LogLevel.Information, Message = "Speaker {SpeakerId} updated")] (:34-35). The class is partial for that generator, and sealed like every other handler here.
  • Why it's built this way: the module could have skipped this file entirely and let AddEntityUpdate register the framework handler closed over UpdateSpeakerCommand. It keeps the subclass for the two vocabulary items, and the registration order is what makes that work: the module scan registers this handler first (MMCA.ADC.Conference.Application/DependencyInjection.cs:133), then AddEntityUpdate runs with TryAdd and therefore does not displace it, while still completing the validator bridge (:150, explained in the block comment at :144-149; the framework side is MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:452-456). It also raises no events of its own, deliberately: domain events belong to the aggregate's mutation methods, and a handler that published something would fire on the generic path and stay silent on a hand-written one (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:33-37).
  • Where it's used: resolved as ICommandHandler<UpdateSpeakerCommand, Result<SpeakerDTO>> by SpeakersController (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:50,351), behind the standard decorator pipeline; after a success the controller evicts the conference:speakers and conference output-cache tags and returns the mapped DTO (:357-358).

ConferenceCategoryCreateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Create · MMCA.ADC.Conference.Application/Categories/UseCases/Create/ConferenceCategoryCreateRequest.cs:10 · Level 7 · record

  • What it is: the inbound contract for creating a conference Category, the aggregate behind vocabularies such as "Level", "Track", and "Session Format". As with the other create slices in this module it is both the HTTP request body and the command the CQRS pipeline dispatches: there is no separate CreateConferenceCategoryCommand.
  • Depends on: ICreateRequest and ICacheInvalidating from MMCA.Common.Application.Interfaces / .UseCases (ConferenceCategoryCreateRequest.cs:2-3,10), the Category domain type used only to build the cache prefix (ConferenceCategoryCreateRequest.cs:1,13), and the ConferenceCategoryIdentifierType alias (= int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:7).
  • Concept: the create-request-as-command shape introduced by EventCreateRequest, applied to the smallest aggregate in the module. Implementing the marker ICreateRequest (ConferenceCategoryCreateRequest.cs:10) is what lets the generic create machinery accept this record straight off the wire, hand it to an IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, and dispatch it as ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>>. Note that it is declared public record class rather than sealed record (ConferenceCategoryCreateRequest.cs:10), the only shape difference from its command siblings in this unit. [Rubric §9, API & Contract Design] assesses whether an inbound contract is explicit about shape and optionality: exactly one member is required (Title, ConferenceCategoryCreateRequest.cs:19), and the other three are optional by declaration, which is the endpoint's optionality documentation. [Rubric §12, Performance & Scalability]: cache eviction is declared, not coded, because the caching decorator reads CachePrefix off the request.
  • Walkthrough: CachePrefix => $"{typeof(Category).FullName}:" (ConferenceCategoryCreateRequest.cs:13) is the eviction key the caching decorator purges on success, keyed on the aggregate root so every cached category read is invalidated together. Id (ConferenceCategoryCreateRequest.cs:16) is a non-nullable ConferenceCategoryIdentifierType, which matters downstream: Category.Create declares its id parameter as nullable (MMCA.ADC.Conference.Domain/Categories/Category.cs:54) precisely so a caller can say "no id", but this request can never express that, so an omitted id binds to 0 and the factory's throw branch (Category.cs:69) is unreachable from the HTTP path (unreachable for this aggregate in any case, since Category carries [IdValueGenerated], Category.cs:15). The reason the factory accepts an id at all is the Sessionize import, which carries category ids assigned upstream (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:99). Title is required (ConferenceCategoryCreateRequest.cs:19), Sort is a plain int display order (ConferenceCategoryCreateRequest.cs:22), and Type is the optional discriminator whose documented examples are "session" and "speaker" (ConferenceCategoryCreateRequest.cs:25). Every member is init-only, so the request cannot be mutated after model binding.
  • Why it's built this way: collapsing request and command removes a mapping step with no behavior of its own, and keeping the id nullable on the domain factory while non-nullable on the wire contract lets one aggregate serve both the API (store-generated keys) and the importer (Sessionize-assigned keys).
  • Where it's used: bound by the ConferenceCategoriesController create action (MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:97-98), which types its base class on it (ConferenceCategoriesController.cs:42) and injects the handler as ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>> (ConferenceCategoriesController.cs:37); validated by ConferenceCategoryCreateRequestValidator, converted by ConferenceCategoryCreateRequestMapper, handled by CreateConferenceCategoryHandler. It is also one half of the AddEntityCrud<Category, ConferenceCategoryDTO, ConferenceCategoryIdentifierType, ConferenceCategoryCreateRequest, ConferenceCategoryUpdateRequest>() registration (MMCA.ADC.Conference.Application/DependencyInjection.cs:143); its edit-side counterpart is ConferenceCategoryUpdateRequest.

QuestionTextRules<T>

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.Validation · MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:12 · Level 7 · class (sealed, generic)

  • What it is: the one reusable FluentValidation rule set for the text of a conference Question. It says a question text must be present and no longer than the domain's limit, and it is written once for every request type that carries such a field.
  • Depends on: FluentValidation.AbstractValidator<T> (NuGet, QuestionValidationRules.cs:2,13), System.Linq.Expressions.Expression<TDelegate> (BCL, QuestionValidationRules.cs:1,15), and QuestionInvariants for the length constant (QuestionValidationRules.cs:3,18).
  • Concept introduced, the generic rule object with a property selector. A FluentValidation validator is generic over the type it validates, so a rule written for one request type cannot normally be reused by another. This codebase solves that by making the rule itself generic in T and taking an Expression<Func<T, string>> in its constructor (QuestionValidationRules.cs:12,15). new QuestionTextRules<QuestionCreateRequest>(p => p.QuestionText) then means "apply the question-text rules to this type's QuestionText property", and a consuming validator pulls the rules in with Include(...), which copies every rule from another AbstractValidator<T> over the same T. The payoff is that create and update cannot drift apart on what a valid question text is: both include this same object. [Rubric §15, Best Practices & Code Quality] assesses whether a change has one edit point: raising the limit is a single edit to the constant, and both the message and the constraint follow. [Rubric §24, Forms, Validation & UX Safety] assesses whether invalid input is rejected at the boundary with actionable messages: each rule carries both human text and a stable machine code.
  • Walkthrough: the whole type is an expression-bodied constructor (QuestionValidationRules.cs:15-18). RuleFor(selector) opens the chain, .NotEmpty() attaches the message "You must enter a Question Text" with error code Question.QuestionText.Required (QuestionValidationRules.cs:17), and .MaximumLength(QuestionInvariants.QuestionTextMaxLength) attaches "Question Text cannot be longer than 1000 characters" with code Question.QuestionText.MaxLength (QuestionValidationRules.cs:18). The limit is 1000, and it is worth following where it comes from: QuestionInvariants.QuestionTextMaxLength is a public const int that forwards to QuestionDTO.QuestionTextMaxLength (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:16), and the literal 1000 is declared once on the wire contract itself (MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:17). The same constant backs the domain guard (QuestionInvariants.cs:62), so the API error message, the domain invariant, and the column width cannot disagree. Two details are worth noticing. First, the error codes are the stable contract: the message wording can change without breaking a client that branches on Question.QuestionText.MaxLength. Second, this class writes its interpolated message with a plain $"..." (QuestionValidationRules.cs:18) where the category rules use string.Create(CultureInfo.InvariantCulture, $"...") (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:20), so the number is formatted with the ambient culture here and invariantly there.
  • Why it's built this way: pulling length limits from the aggregate's invariants class rather than restating them in the validator is what keeps three layers (validation, domain guard, column constraint) on one number, and rooting that number in the Shared DTO means the client can render the same limit in a character counter. Each aggregate owns its own invariants class, which is why this rule reads QuestionInvariants and not a shared constants bag. Note the small difference between the two invariants classes: question limits are const (QuestionInvariants.cs:16) while category limits are public static readonly int (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:18,24), so the former are baked into each calling assembly at compile time and the latter are read at runtime.
  • Where it's used: included by QuestionCreateRequestValidator (MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestValidator.cs:10) and QuestionUpdateRequestValidator (MMCA.ADC.Conference.Application/Questions/UseCases/Update/QuestionUpdateRequestValidator.cs:10), which are its only two consumers.

RemoveCategoryItemCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.RemoveCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/RemoveCategoryItem/RemoveCategoryItemCommand.cs:12 · Level 7 · record (sealed)

  • What it is: the write intent for removing one CategoryItem from its owning Category. It names the category and the item, and nothing else.
  • Depends on: ICacheInvalidating (RemoveCategoryItemCommand.cs:2,14), the Category type for the cache prefix (RemoveCategoryItemCommand.cs:1,17), and the ConferenceCategoryIdentifierType / CategoryItemIdentifierType aliases (both = int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7).
  • Concept: the remove-child command shape, the mirror of AddCategoryItemCommand and deliberately narrower than it. An add carries the child's payload; a remove carries only the two identifiers, because the child already exists and the only decision left is which one. Note that both identifiers here are non-nullable (RemoveCategoryItemCommand.cs:13-14), where the add's child id is optional: an add may invent identity, a remove may only reference it. The pair (CategoryId, CategoryItemId) is what makes the operation aggregate-scoped: the handler loads the category and asks it to remove the item, so no caller can delete an item by id alone and bypass the aggregate's rules. The cache prefix is keyed on Category, not on the child, because a cached category read carries its items inline. [Rubric §4, Domain-Driven Design] assesses whether children are mutated through their root: the command's shape makes any other access path impossible to express.
  • Walkthrough: a sealed record with two positional parameters, CategoryId and CategoryItemId (RemoveCategoryItemCommand.cs:12-14), plus the single computed CachePrefix => $"{typeof(Category).FullName}:" (RemoveCategoryItemCommand.cs:17). There is no validator class in the folder, because two required identifiers have nothing to check beyond model binding, and there is no RowVersion member: unlike the conditional category update, a category-item removal states no optimistic-concurrency precondition, which is exactly the case the framework's RowVersion(command) hook returns null for (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:91). Note what CategoryId is allowed to be: the DELETE endpoint takes it as a [FromQuery] argument with no [Required] and no default (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:182), so it legitimately arrives as 0, and RemoveCategoryItemHandler is the piece that copes with that.
  • Why it's built this way: keeping the payload to two identifiers means the command is fully described by the route plus one query argument, and it leaves the aggregate as the only place that knows what removing an item means (a soft delete on the child through RemoveChildOrNotFound plus a CategoryItemChanged domain event, MMCA.ADC.Conference.Domain/Categories/Category.cs:188-194).
  • Where it's used: constructed by the CategoryItemsController as new RemoveCategoryItemCommand(categoryId, id) (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:186, handler injected at CategoryItemsController.cs:67) and handled by RemoveCategoryItemHandler. Its add and update counterparts are AddCategoryItemCommand and UpdateCategoryItemCommand.

UpdateCategoryItemCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommand.cs:14 · Level 7 · record (sealed)

  • What it is: the write intent for editing one CategoryItem inside its owning Category: the two identifiers that locate it plus the two fields that may change.
  • Depends on: ICacheInvalidating (UpdateCategoryItemCommand.cs:2,18), the Category type for the cache prefix (UpdateCategoryItemCommand.cs:1,21), and the ConferenceCategoryIdentifierType / CategoryItemIdentifierType aliases.
  • Concept: the update-child shape, which sits between the add and remove shapes. It carries the aggregate id and the child id like a remove, plus exactly the fields that are editable and no others: Name is a non-nullable string and Sort a non-nullable int (UpdateCategoryItemCommand.cs:17-18), so this command cannot be used to blank a name by omission. Because both editable fields are here rather than spread across a partial-update document, the record is the complete answer to "what can this endpoint change", and it is also the exact target the validator binds to. [Rubric §9, API & Contract Design] assesses whether an edit contract states precisely what is mutable: the four positional parameters are that statement. [Rubric §6, CQRS & Event-Driven]: one command type, one handler, one write path, with the resulting CategoryItemChanged domain event raised inside the aggregate (MMCA.ADC.Conference.Domain/Categories/Category.cs:176).
  • Walkthrough: four positional parameters, CategoryId, CategoryItemId, Name, Sort (UpdateCategoryItemCommand.cs:14-18), with CachePrefix => $"{typeof(Category).FullName}:" (UpdateCategoryItemCommand.cs:21). Unlike RemoveCategoryItemCommand this command is always fully populated: the controller reads the item id from the route and the owning category id from a required member of the request body, UpdateCategoryItemRequest (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:42-51,161-166), so the handler never has to hunt for the owner. That single difference in what the wire guarantees is why the update handler keeps the framework's default by-id load and the remove handler does not.
  • Why it's built this way: a narrow, explicitly-typed update command keeps the write surface auditable and gives the caching decorator a well-defined eviction boundary; a general "patch the item entity" contract would have neither property, and it could not be validated by a single AbstractValidator<T>.
  • Where it's used: constructed by the CategoryItemsController (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:162, handler injected at CategoryItemsController.cs:66), validated by UpdateCategoryItemCommandValidator, and handled by UpdateCategoryItemHandler.

ConferenceCategoryCreateRequestMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Create · MMCA.ADC.Conference.Application/Categories/UseCases/Create/ConferenceCategoryCreateRequestMapper.cs:11 · Level 8 · class (sealed)

  • What it is: the one adapter that turns a ConferenceCategoryCreateRequest into a Category domain entity, by calling the aggregate's Create factory and returning whatever Result<T> it produces.
  • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> from MMCA.Common.Application.Interfaces (ConferenceCategoryCreateRequestMapper.cs:2,11-12), the Category aggregate and its ConferenceCategoryIdentifierType alias (ConferenceCategoryCreateRequestMapper.cs:1), and Result<T> from MMCA.Common.Shared.Abstractions (ConferenceCategoryCreateRequestMapper.cs:3,15).
  • Concept: request-to-entity mapping as a separate injectable role. The generic create pipeline never constructs entities itself: CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> resolves an IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> and asks it for one (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:44,90), which is what lets one workflow serve every aggregate while each aggregate keeps its own construction rules. Two properties matter. First, it is a plain sealed class with no [Mapper] attribute (ConferenceCategoryCreateRequestMapper.cs:11): unlike the read-side DTO mappers in this unit, request-to-entity conversion is deliberately hand-written, because it must go through a factory that can fail, which a property-copy generator cannot express. Second, it returns Task<Result<Category>> rather than a Category, so an invalid request produces a failure value that flows back as a 400-class response instead of an exception. [Rubric §3, Clean Architecture] assesses whether the domain stays independent of the delivery mechanism: the controller knows a request type, the domain knows a factory, and this class is the only thing that knows both. [Rubric §4, Domain-Driven Design]: the factory stays the single construction path, so no invariant can be bypassed by new. See ADR-001 for the no-reflection mapping policy.
  • Walkthrough: one method. CreateEntityAsync(ConferenceCategoryCreateRequest request, CancellationToken) (ConferenceCategoryCreateRequestMapper.cs:15) null-guards with ArgumentNullException.ThrowIfNull(request) (ConferenceCategoryCreateRequestMapper.cs:17), then returns Task.FromResult(Category.Create(request.Id, request.Title, request.Sort, request.Type)) (ConferenceCategoryCreateRequestMapper.cs:19-23). The method is synchronous in substance: Task.FromResult satisfies the async contract without a state machine, because the factory does no I/O. Inside the factory (MMCA.ADC.Conference.Domain/Categories/Category.cs:54-75) the title is checked by CategoryInvariants.EnsureTitleIsValid through Result.Combine (Category.cs:61), the id is resolved against the [IdValueGenerated] check (Category.cs:65,69), and a CategoryChanged domain event with DomainEntityState.Added is queued on the new aggregate (Category.cs:72) for the outbox to pick up at save time. Because the request's Id is non-nullable (ConferenceCategoryCreateRequest.cs:16), what actually reaches the factory is 0 when the client omits it.
  • Why it's built this way: pushing construction into Category.Create means the invariant check and the domain event run for every caller, not just HTTP ones (the Sessionize importer calls the same factory at MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/CategorySyncStrategy.cs:99). The mapper adds no rules of its own, which is exactly what makes it safe to have several entry points into the same aggregate.
  • Where it's used: injected into CreateConferenceCategoryHandler as IEntityRequestMapper<Category, ConferenceCategoryCreateRequest, ConferenceCategoryIdentifierType> (MMCA.ADC.Conference.Application/Categories/UseCases/Create/CreateConferenceCategoryHandler.cs:17), registered by the module's convention scan rather than an explicit AddScoped line (MMCA.ADC.Conference.Application/DependencyInjection.cs:133).

ConferenceCategoryCreateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Create · MMCA.ADC.Conference.Application/Categories/UseCases/Create/ConferenceCategoryCreateRequestValidator.cs:7 · Level 8 · class (sealed)

  • What it is: the FluentValidation validator the pipeline runs against a ConferenceCategoryCreateRequest before CreateConferenceCategoryHandler sees it. It contains no rules of its own: it is a single Include call.
  • Depends on: FluentValidation.AbstractValidator<T> (NuGet, ConferenceCategoryCreateRequestValidator.cs:1,7) and ConferenceCategoryTitleRules<T> from MMCA.ADC.Conference.Application.Categories.Validation (ConferenceCategoryCreateRequestValidator.cs:2,10).
  • Concept: rule composition by Include with a property selector, the mechanism taught by QuestionTextRules<T>. Include(new ConferenceCategoryTitleRules<ConferenceCategoryCreateRequest>(p => p.Title)) (ConferenceCategoryCreateRequestValidator.cs:10) copies the shared title rules onto this request's Title property, and the update-side validator includes the same rule object against ConferenceCategoryUpdateRequest, so create and update cannot disagree about what a valid title is. Equally instructive is what is not validated: Sort and Type have no rules at all here, even though a sibling rule object for a sort value exists (CategoryItemSortRules<T>, applied only to category items by AddCategoryItemCommandValidator and UpdateCategoryItemCommandValidator). A negative Sort on a category is therefore accepted. [Rubric §24, Forms, Validation & UX Safety] assesses whether invalid input is rejected at the boundary: the title path is covered, the sort path is not. [Rubric §15, Best Practices & Code Quality]: one shared rule object is one edit point instead of one per slice.
  • Walkthrough: the whole type is an expression-bodied constructor (ConferenceCategoryCreateRequestValidator.cs:9-10). The rule bodies it pulls in live at MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:17-20: NotEmpty with code Category.Title.Required (ConferenceCategoryValidationRules.cs:19), then MaximumLength(CategoryInvariants.TitleMaxLength) with code Category.Title.MaxLength (ConferenceCategoryValidationRules.cs:20), where the limit is 255, declared on the wire contract (MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:17) and forwarded by the domain (MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:18). Note the domain declares that forward as public static readonly int rather than const, so it is read at runtime rather than baked into each caller.
  • Why it's built this way: validating at the pipeline boundary gives the caller a complete, field-addressed error list in one round trip, while Category.Create keeps its own EnsureTitleIsValid check (MMCA.ADC.Conference.Domain/Categories/Category.cs:61) as the backstop for non-HTTP callers such as the Sessionize import. The duplication is intentional and cheap because both sides read the same constant.
  • Where it's used: resolved by the validation decorator around CreateConferenceCategoryHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered directly by ConferenceCategoryCreateRequestValidatorTests (MMCA.ADC.Conference.Application.Tests/Categories/Validation/ConferenceCategoryCreateRequestValidatorTests.cs:7).

EventQuestionAnswerDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:12 · Level 8 · class (sealed partial)

  • What it is: the read-side mapper that turns an EventQuestionAnswer domain entity into an EventQuestionAnswerDTO. The single-entity method has no body in this file: Mapperly generates it at compile time.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> from MMCA.Common.Application.Interfaces (MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:3,13), Riok.Mapperly.Abstractions (NuGet, EventQuestionAnswerDTOMapper.cs:4,11), the EventQuestionAnswer entity (EventQuestionAnswerDTOMapper.cs:1), the EventQuestionAnswerDTO contract from the Shared project (EventQuestionAnswerDTOMapper.cs:2), and the EventQuestionAnswerIdentifierType alias (= int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:9).
  • Concept introduced (for this unit), source-generated DTO mapping. [Mapper] on a partial class (EventQuestionAnswerDTOMapper.cs:11-12) tells the Mapperly generator to fill in the body of every partial method it finds, here MapToDTO (EventQuestionAnswerDTOMapper.cs:16). The generated body is straight-line property assignment: no reflection, no expression trees, no runtime configuration, and a compile error rather than a silent null if a target member has no source. That is the whole point of ADR-001: mapping is either hand-written or generated, never reflective. [Rubric §12, Performance & Scalability] assesses whether hot paths avoid avoidable runtime work: every read endpoint maps its result set, so a generated assignment beats a reflective copy at the exact place volume lands. [Rubric §9, API & Contract Design] assesses what crosses the wire: the DTO, not the entity, so a domain refactor cannot silently reshape the JSON. [Rubric §14, Testability] assesses whether logic can be exercised in isolation: the mapper is a pure function of its input and is tested directly (EventQuestionAnswerDTOMapperTests).
  • Walkthrough: two members. public partial EventQuestionAnswerDTO MapToDTO(EventQuestionAnswer entity) (EventQuestionAnswerDTOMapper.cs:16) is the declaration whose implementation the generator supplies. MapToDTOs(IReadOnlyCollection<EventQuestionAnswer>) (EventQuestionAnswerDTOMapper.cs:19-23) is hand-written and deliberately so: it null-guards with ArgumentNullException.ThrowIfNull (EventQuestionAnswerDTOMapper.cs:21) and then projects with a collection expression over a spread, [.. entityCollection.Select(MapToDTO)] (EventQuestionAnswerDTOMapper.cs:22), which materializes a single array without an intermediate List<T> growth cycle. The collection method delegating to the generated single-item method is the shape every mapper in this family repeats.
  • Where it's used: injected concretely into AddEventQuestionAnswerHandler (MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:22, used at :93 and :116), consumed as a child mapper by EventDTOMapper through [UseMapper] (MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:27-28), and resolved as IEntityDTOMapper<EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswerIdentifierType> by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for the entity (MMCA.ADC.Conference.Application/DependencyInjection.cs:107, constructor parameter at MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:36). Registration of the mapper itself is by the module's convention scan, services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:133).

EventSpeakerDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/EventSpeakerDTOMapper.cs:12 · Level 8 · class (sealed partial)

  • What it is: the same generated mapper for the EventSpeaker association entity to EventSpeakerDTO.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (MMCA.ADC.Conference.Application/Events/DTOs/EventSpeakerDTOMapper.cs:3,13), Mapperly (EventSpeakerDTOMapper.cs:4,11), the entity and DTO (EventSpeakerDTOMapper.cs:1-2), and the EventSpeakerIdentifierType alias (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:10).
  • Concept: nothing new; the [Mapper]-plus-partial shape taught by EventQuestionAnswerDTOMapper. Reading the two side by side is the fastest way to see how little varies: the entity, the DTO, and the identifier alias in the interface arguments, and nothing else.
  • Walkthrough: public partial EventSpeakerDTO MapToDTO(EventSpeaker entity) (EventSpeakerDTOMapper.cs:16) generated by Mapperly, and the hand-written MapToDTOs with its null guard and spread projection (EventSpeakerDTOMapper.cs:19-23).
  • Where it's used: injected concretely into AddEventSpeakerHandler, which uses it for the MapChild hook (MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:17,34), used as a child mapper by EventDTOMapper (MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:24-25), and resolved by the query service registered at MMCA.ADC.Conference.Application/DependencyInjection.cs:104. Tested by EventSpeakerDTOMapperTests.

PublishedEventSpecification

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.Specifications · MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:11 · Level 8 · class (sealed)

  • What it is: a one-line query filter object that restricts an event query to published events only. It is how BR-108 ("non-privileged readers see only published events") is expressed as data rather than as an if inside every read endpoint (MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:7-9).
  • Depends on: Specification<TEntity, TIdentifierType> from MMCA.Common.Domain.Specifications (PublishedEventSpecification.cs:3,11), the Event aggregate and its EventIdentifierType alias (PublishedEventSpecification.cs:2,11), and System.Linq.Expressions (PublishedEventSpecification.cs:1,14).
  • Concept introduced, authorization expressed as a specification. The specification pattern itself is taught in Group 03; what this type introduces is using it as a security filter. The base class exposes a Criteria expression that the repository composes into the EF query, so the restriction is applied in SQL rather than after materialization: an unpublished event is never loaded, never counted in a page total, and never reaches the serializer. Because the filter is a first-class object, the decision "does this caller get the filter" becomes a nullable value at one call site instead of branching query code in five actions. [Rubric §11, Security] assesses whether authorization is enforced at the data boundary rather than in the view: a non-privileged reader's query cannot return an unpublished row at all, and a filtered-out row answers 404 rather than 403, so the response does not confirm that the id exists (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:588-590). [Rubric §12, Performance & Scalability] assesses over-fetching: pushing the predicate into the expression tree keeps paging counts correct and avoids materializing rows the caller may not see.
  • Walkthrough: the whole type is a single expression-bodied override, public override Expression<Func<Event, bool>> Criteria => e => e.IsPublished (PublishedEventSpecification.cs:14). There is no constructor and no state, so an instance is cheap to allocate per request, which matters because the framework builds it once per request and reuses it across every page of an export loop (EntityControllerBase.cs:592-595).
  • Why it's built this way: keeping BR-108 in one named type means the event read endpoints share one definition of "visible event", and the class name makes the business rule greppable. Inheriting from the framework Specification<TEntity, TIdentifierType> base lets the same object flow through the generic query service and repository without an ADC-specific overload.
  • Where it's used: EventsController supplies it from exactly one override, protected override Specification<Event, EventIdentifierType>? GetExportSpecification(), which returns null (no filter) when currentUserService.IsPrivilegedConferenceReader() and a new instance otherwise (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:75-76). That is the framework's synchronous scoping hook: GetReadSpecificationAsync returns ValueTask.FromResult(GetExportSpecification()) by default (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:597-599), and every read action (both GetAllAsync overloads, GetAllForLookupAsync, GetByIdAsync and ExportAsync) applies it (EntityControllerBase.cs:562-567). The ADC read actions themselves are attribute-only passthroughs to their base implementations (EventsController.cs:77-124), so the filter is stated once and cannot drift between list and export.
  • Caveats / not-in-source: the gate is the read audience, not the Organizer role alone: IsPrivilegedConferenceReader() is the shared predicate, and the same call also guards the export action outright with a Forbid() before the base implementation runs (EventsController.cs:141-144), so today a non-privileged caller is denied the CSV rather than served a filtered one. The criteria here do not exclude soft-deleted rows; that is handled separately by the EF global query filter (see Group 07).

RoomDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/RoomDTOMapper.cs:12 · Level 8 · class (sealed partial)

  • What it is: the generated mapper from a Room child entity to a RoomDTO.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (MMCA.ADC.Conference.Application/Events/DTOs/RoomDTOMapper.cs:3,13), Mapperly (RoomDTOMapper.cs:4,11), the entity and DTO (RoomDTOMapper.cs:1-2), and the RoomIdentifierType alias (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:12).
  • Concept: nothing new; the [Mapper]-plus-partial shape taught by EventQuestionAnswerDTOMapper.
  • Walkthrough: public partial RoomDTO MapToDTO(Room entity) (RoomDTOMapper.cs:16) generated by Mapperly, plus the hand-written MapToDTOs with null guard and spread projection (RoomDTOMapper.cs:19-23).
  • Where it's used: injected concretely into AddRoomHandler, which maps the room it added out of the mutation context to build its result (MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:26,168), used as a child mapper by EventDTOMapper (MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:21-22), and resolved by the query service registered at MMCA.ADC.Conference.Application/DependencyInjection.cs:98. Tested by RoomDTOMapperTests.

UpdateCategoryItemCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:7 · Level 8 · class (sealed)

  • What it is: the validator the pipeline runs against an UpdateCategoryItemCommand. Like its add-side twin it holds no rule bodies: it is two Include calls.
  • Depends on: FluentValidation.AbstractValidator<T> (NuGet, UpdateCategoryItemCommandValidator.cs:1,7) and the two shared rule objects CategoryItemNameRules<T> and CategoryItemSortRules<T> from MMCA.ADC.Conference.Application.Categories.Validation (UpdateCategoryItemCommandValidator.cs:2,11-12).
  • Concept: validating a command rather than a request. ConferenceCategoryCreateRequestValidator targets a wire contract; this one targets the record the controller assembles from a route value plus a body (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:161-166). The pipeline treats them identically because both are just T to FluentValidation, which is what makes it possible to validate at the same boundary whether or not a slice has a distinct request type. The two included rule objects also show the generic-rule pattern working over different selector types and across the repo boundary: CategoryItemNameRules<T> is an ADC rule taking an Expression<Func<T, string>> (MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:28,31), while CategoryItemSortRules<T> is a two-line subclass of the framework's NonNegativeIntRules<T> that supplies only the field phrase and the error code (ConferenceCategoryValidationRules.cs:41-45). [Rubric §24, Forms, Validation & UX Safety] assesses boundary rejection with actionable messages: both rules carry stable codes, CategoryItem.Name.Required / CategoryItem.Name.MaxLength (ConferenceCategoryValidationRules.cs:33-34) and CategoryItem.Sort.Negative (ConferenceCategoryValidationRules.cs:45). [Rubric §1, SOLID]: name rules and sort rules are separate objects with separate reasons to change, and the validator composes them instead of inheriting a fat base.
  • Walkthrough: a block-bodied constructor with two statements (UpdateCategoryItemCommandValidator.cs:9-13): Include(new CategoryItemNameRules<UpdateCategoryItemCommand>(p => p.Name)) (UpdateCategoryItemCommandValidator.cs:11) and Include(new CategoryItemSortRules<UpdateCategoryItemCommand>(p => p.Sort)) (UpdateCategoryItemCommandValidator.cs:12). The name rule enforces NotEmpty plus MaximumLength(CategoryInvariants.CategoryItemNameMaxLength), which is 500 (MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:16, forwarded at MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:24); the sort rule resolves to the framework's GreaterThanOrEqualTo(0) with the message "Sort order must be greater than or equal to 0" (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:124-126). What this validator does not check is uniqueness of the name within the category: that rule (BR-138) needs the sibling collection and therefore lives in the aggregate, where the update path passes the item being edited as the exclusion (MMCA.ADC.Conference.Domain/Categories/Category.cs:167-168).
  • Why it's built this way: field-shape rules that need only the incoming values run at the boundary where they can be reported as a complete list; rules that need loaded state run in the domain. Splitting them that way is why the validator can stay a pure, dependency-free composition with no repository dependency.
  • Where it's used: resolved by the validation decorator around UpdateCategoryItemHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered by UpdateCategoryItemCommandValidatorTests, which shares a file with its add-side twin (MMCA.ADC.Conference.Application.Tests/Categories/Validation/CategoryCommandValidatorTests.cs:54). Its add-side sibling is AddCategoryItemCommandValidator, which is the same two Include calls over a different command (MMCA.ADC.Conference.Application/Categories/UseCases/AddCategoryItem/AddCategoryItemCommandValidator.cs:11-12).

CreateConferenceCategoryHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Create · MMCA.ADC.Conference.Application/Categories/UseCases/Create/CreateConferenceCategoryHandler.cs:15 · Level 9 · class (sealed partial)

  • What it is: the handler that creates a conference Category. It is fourteen lines long, and none of them orchestrate anything: the create workflow lives in the framework base class, and this type supplies the four collaborators plus the one log message that is specific to categories.
  • Depends on: CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> from MMCA.Common.Application.UseCases (CreateConferenceCategoryHandler.cs:7,20-21), IUnitOfWork (CreateConferenceCategoryHandler.cs:6,16), IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> satisfied by ConferenceCategoryCreateRequestMapper (CreateConferenceCategoryHandler.cs:5,17), ConferenceCategoryDTOMapper (CreateConferenceCategoryHandler.cs:2,18), ILogger<T> with a source-generated [LoggerMessage] partial (CreateConferenceCategoryHandler.cs:1,19,26-27), the Category aggregate (CreateConferenceCategoryHandler.cs:3), and ConferenceCategoryDTO from the Shared project (CreateConferenceCategoryHandler.cs:4).
  • Concept introduced (for this unit), the create workflow as an inherited template method. The base is an abstract class that implements ICommandHandler<TCreateRequest, Result<TEntityDTO>> rather than a decorator or a helper, and the reason is registration: the concrete subclass stays the registered handler, so the module scan keeps discovering it and the decorator pipeline keeps wrapping it, while Scrutor never registers the abstract base (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:17-23,42-46). Read the four constructor arguments as a pipeline (CreateConferenceCategoryHandler.cs:15-19): the request mapper turns the wire contract into a validated entity, the unit of work supplies the typed repository and owns the transaction boundary, and the DTO mapper turns the persisted entity back into a wire contract. Notice the asymmetry in how the two mappers are declared: the request mapper arrives by interface (CreateConferenceCategoryHandler.cs:17) because the base is written against that abstraction, while the DTO mapper arrives by concrete type (CreateConferenceCategoryHandler.cs:18) and widens to the base's IEntityDTOMapper<...> parameter. Notice equally what is absent: no validator call (the validation decorator already ran ConferenceCategoryCreateRequestValidator), no cache eviction (the caching decorator reads CachePrefix off the request), and no try/catch (failures arrive as Result values). That absence is the point of the decorator pipeline taught in Group 05. [Rubric §5, Vertical Slice] assesses whether a use case is self-contained: the four Create types live in one folder and this handler is the slice's entry point. [Rubric §3, Clean Architecture]: the Application layer depends on abstractions and on the Domain, never on EF Core or ASP.NET. [Rubric §6, CQRS & Event-Driven]: one command type, one handler, one write path, and the CategoryChanged event raised inside the factory (MMCA.ADC.Conference.Domain/Categories/Category.cs:72) is captured by the base's single SaveChangesAsync. [Rubric §15, Best Practices & Code Quality]: the workflow exists once, so a fix to the create sequence reaches every module at the same time.
  • Walkthrough: primary-constructor injection of the four collaborators, forwarded to the base's own primary constructor (CreateConferenceCategoryHandler.cs:15-21). Only two members are declared here. LogCreated(Category entity) overrides the base's no-op logging hook and calls the source-generated partial LogConferenceCategoryCreated(logger, entity.Id, entity.Title) (CreateConferenceCategoryHandler.cs:24, message template at CreateConferenceCategoryHandler.cs:26-27); the hook is invoked after the save (CreateEntityHandlerBase.cs:99), so the logged id is the store-generated key rather than the 0 that arrived on the request. The inherited sequence is worth reading once: HandleAsync null-guards the command and delegates to CreateCoreAsync (CreateEntityHandlerBase.cs:56-63), which runs the pass-through PrepareAsync hook (CreateEntityHandlerBase.cs:84,114-118), awaits requestMapper.CreateEntityAsync and short-circuits a factory failure into the right generic shape with Result.Failure<TEntityDTO>(result.Errors) (CreateEntityHandlerBase.cs:90-92), resolves attemptUnitOfWork.GetRepository<TEntity, TIdentifierType>() (CreateEntityHandlerBase.cs:95), calls PersistAsync, whose default is AddAsync followed by exactly one SaveChangesAsync (CreateEntityHandlerBase.cs:129-140), then LogCreated, then the no-op OnCreatedAsync post-commit hook, and finally returns Result.Success(dtoMapper.MapToDTO(entity)) (CreateEntityHandlerBase.cs:97-102). This handler overrides none of PrepareAsync, PersistAsync or OnCreatedAsync: a category needs no app-assigned key, no bespoke persist step, and no post-commit publish.
  • Why it's built this way: the single SaveChangesAsync is the one place audit fields are stamped, domain events are captured, and outbox rows are written (ADR-003), so the workflow deliberately owns exactly one call to it and a subclass that wants a different persist step has to say so by overriding PersistAsync. Note also why CreateCoreAsync takes the unit of work as a parameter rather than closing over the injected one: a create path that computes its own primary key and retries on a unique-constraint collision must run each attempt against a fresh scope's unit of work, because the ambient DbContext still tracks the failed insert (CreateEntityHandlerBase.cs:29-36,69-73). Returning a ConferenceCategoryDTO rather than the entity keeps the domain type from crossing the API boundary, per ADR-001.
  • Where it's used: resolved by the ConferenceCategoriesController as ICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>> (MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:38) and reached through AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (ConferenceCategoriesController.cs:42), whose create action the ADC controller overrides only to evict the conference:categories output-cache tag after the call (ConferenceCategoriesController.cs:95-103). The category's edit path is not a sibling handler: it is the framework's generic UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> plus a ConferenceCategoryUpdateApplier, wired by the same AddEntityCrud<...>() call (MMCA.ADC.Conference.Application/DependencyInjection.cs:143). Covered by CreateConferenceCategoryHandlerTests (MMCA.ADC.Conference.Application.Tests/Categories/UseCases/CreateConferenceCategoryHandlerTests.cs:13).

EventDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.DTOs · MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:15 · Level 9 · class (sealed partial)

  • What it is: the read-side mapper for the Event aggregate. It is the composite of the family: it maps the root, delegates its three child collections to the child mappers, then applies one hand-written fix-up the generator cannot express.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> (MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:3,19), Mapperly (EventDTOMapper.cs:5,14,21,24,27,51), RoomDTOMapper, EventSpeakerDTOMapper, and EventQuestionAnswerDTOMapper (EventDTOMapper.cs:16-18), the Event aggregate (EventDTOMapper.cs:1), EventDTO from the Shared project (EventDTOMapper.cs:2), the Email value object from Common's shared contact value objects (EventDTOMapper.cs:4,55), and System.Globalization.CultureInfo (BCL, EventDTOMapper.cs:40).
  • Concept introduced, composing generated mappers and escaping to hand-written code. Three Mapperly features carry this class. [UseMapper] on a field (EventDTOMapper.cs:21,24,27) tells the generator "when you need to map a Room, an EventSpeaker, or an EventQuestionAnswer, call this instance instead of generating a second copy", which is how an aggregate DTO gets its child collections filled without duplicating child mapping logic. [MapperIgnoreTarget] (EventDTOMapper.cs:51) is the escape hatch: it tells the generator to leave one target member alone so the class can set it itself. The member in question is LastSessionizeRefreshBy, a UserIdentifierType? on the entity (MMCA.ADC.Conference.Domain/Events/Event.cs:86) but a string? on the DTO (MMCA.ADC.Conference.Shared/Events/EventDTO.cs:100); a nullable-value-type-to-string conversion is not something the generator will invent, and the file's own comment says so (EventDTOMapper.cs:36). The third feature is the private user-defined mapping method: private static string? NullableEmailToString(Email? email) => email?.Value (EventDTOMapper.cs:54-55) is not called by any code in this file, it is picked up by the generator as the conversion to use whenever it needs an Email? on the entity as a string? on the DTO. That is the case for OrganizerContactEmail, an Email? value object on the aggregate (MMCA.ADC.Conference.Domain/Events/Event.cs:59) and a plain string? on the DTO (MMCA.ADC.Conference.Shared/Events/EventDTO.cs:82), per ADR-068. Note the difference from the LastSessionizeRefreshBy case: a one-line unwrap the generator can call for itself stays declarative, while a conversion needing a CultureInfo decision is lifted into the hand-written wrapper. The general lesson is that a source generator is not all-or-nothing: keep generation for the ninety percent of straight property copies and hand-write only the member that needs a decision. [Rubric §12, Performance & Scalability] assesses avoidable runtime work on hot paths: the whole event read path, including children, is generated assignment plus one with expression. [Rubric §9, API & Contract Design] assesses wire shape: the DTO's own types are chosen for the wire (an id rendered as a string), and this class is where the two type systems meet. [Rubric §15, Best Practices & Code Quality] assesses correctness details: the conversion uses CultureInfo.InvariantCulture (EventDTOMapper.cs:39-40) rather than the ambient culture, so the value is stable regardless of server locale.
  • Walkthrough: the primary constructor takes the three child mappers (EventDTOMapper.cs:15-18) and assigns each to a [UseMapper]-annotated readonly field (EventDTOMapper.cs:21-28). The public MapToDTO(Event entity) (EventDTOMapper.cs:31-42) is hand-written: it null-guards (EventDTOMapper.cs:33), calls the private generated MapToDTOGenerated(entity) (EventDTOMapper.cs:34), and then returns a with expression that sets the one ignored member, LastSessionizeRefreshBy = entity.LastSessionizeRefreshBy?.ToString(System.Globalization.CultureInfo.InvariantCulture) (EventDTOMapper.cs:37-41). Because EventDTO is a record, the with copy is a cheap shallow clone that leaves every generated assignment intact. MapToDTOs (EventDTOMapper.cs:45-49) is the same null-guarded spread projection as its siblings. The generated method is declared next, private partial EventDTO MapToDTOGenerated(Event entity) carrying [MapperIgnoreTarget(nameof(EventDTO.LastSessionizeRefreshBy))] (EventDTOMapper.cs:51-52), and the file ends with the one-line NullableEmailToString helper the generator consumes (EventDTOMapper.cs:54-55).
  • Why it's built this way: making the public method the wrapper and the generated method private means no caller can accidentally bypass the fix-up and receive a DTO with a null LastSessionizeRefreshBy. Keeping the child mappers injected rather than generated inline means the same RoomDTO shape is produced whether a room is read directly or as part of an event, per ADR-001.
  • Where it's used: injected into CreateEventHandler (MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:18) and UpdateEventHandler (MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:20), and resolved as IEntityDTOMapper<Event, EventDTO, EventIdentifierType> by the event query service (MMCA.ADC.Conference.Application/DependencyInjection.cs:69, constructor parameter at MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:36). Registration is by the module's convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133). Tested by EventDTOMapperTests.

UpdateCategoryItemHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.UpdateCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemHandler.cs:19 · Level 10 · class (sealed partial)

  • What it is: the handler for UpdateCategoryItemCommand. It declares four things (which child collection to eager-load, which id addresses the aggregate, which domain method to call, and what to log) and inherits everything else from the framework's load-mutate-save workflow.
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> from MMCA.Common.Application.UseCases (UpdateCategoryItemHandler.cs:4,22), IUnitOfWork (UpdateCategoryItemHandler.cs:3,20), ILogger<T> with a source-generated [LoggerMessage] (UpdateCategoryItemHandler.cs:1,21,41-42), the Category aggregate and its CategoryItem child (UpdateCategoryItemHandler.cs:2), and Result (UpdateCategoryItemHandler.cs:5).
  • Concept introduced (for this unit), the load-mutate-save template method. The shared machinery lives in MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:52), and the piece worth understanding is why the core deliberately does not implement ICommandHandler<,> itself: a real handler returns one of three shapes (a bare Result for verb-style commands, a Result<T> carrying the refreshed DTO, a Result<T> carrying a purpose-built payload), and a single type advertising two handler interfaces would register a bogus second handler entry during the module scan (MutateEntityHandlerBase.cs:17-28). So the core is abstract machinery and three thin subclasses close it over a return shape; this handler picks the bare-Result one (MutateEntityHandlerBase.cs:320-333). The choice is visible in the endpoint: a category-item update answers 204 No Content (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:175), so there is nothing to map back. Compare this handler line by line with RemoveCategoryItemHandler: identical primary constructor, identical Includes, identical EntityId, and the only structural difference is that the remove overrides LoadAsync and this one does not, because an update always arrives with its owner id in the body. [Rubric §4, Domain-Driven Design] assesses whether the aggregate owns its invariants: the handler never touches the child collection itself, and the BR-138 case-insensitive uniqueness check that makes this operation interesting lives inside Category.UpdateCategoryItem (MMCA.ADC.Conference.Domain/Categories/Category.cs:167-168). [Rubric §14, Testability]: with only a unit of work and a logger injected, the handler is exercised in the unit tier against a faked repository (UpdateCategoryItemHandlerTests).
  • Walkthrough: primary-constructor injection of IUnitOfWork and ILogger<UpdateCategoryItemHandler>, with the unit of work forwarded to the base (UpdateCategoryItemHandler.cs:19-22). Four overrides follow. Includes => [nameof(Category.CategoryItems)] (UpdateCategoryItemHandler.cs:25) is load-bearing: without the include the child collection is empty and the domain method finds nothing to update. EntityId(command) => command.CategoryId (UpdateCategoryItemHandler.cs:28) is how the base addresses the aggregate. MutateAsync is one line, Task.FromResult(entity.UpdateCategoryItem(command.CategoryItemId, command.Name, command.Sort)) (UpdateCategoryItemHandler.cs:31-35); the signature is asynchronous so that a mutation needing a cross-service lookup fits the same hook, and a purely synchronous domain call wraps with Task.FromResult (MutateEntityHandlerBase.cs:100-106). LogMutated calls the generated partial with both identifiers as structured fields (UpdateCategoryItemHandler.cs:38-39, template at :41-42). The inherited body then reads: resolve the repository from the unit of work (MutateEntityHandlerBase.cs:280), load through LoadAsync, whose default is GetByIdAsync(EntityId(command), Includes, AsTracking, ...) with AsTracking defaulting to true because a no-tracking load would turn the mutation into a silent no-op (MutateEntityHandlerBase.cs:72-76,152-160), fail with Error.NotFound.WithSource(HandlerName).WithTarget(typeof(TEntity).Name) when the row is absent (MutateEntityHandlerBase.cs:282-283, HandlerName defaulting to the concrete type's own name at :62), skip the row-version stamp because RowVersion(command) is null for an unconditional endpoint (MutateEntityHandlerBase.cs:91,291-292), run the mutation and return its errors unchanged on failure (MutateEntityHandlerBase.cs:294-296), then save once, log, and run the no-op post-commit hook (MutateEntityHandlerBase.cs:303-306). Inside the aggregate the order of checks matters: the child is located first (Category.cs:161), then the uniqueness rule runs excluding the item being edited (Category.cs:167-168), then the child's own Update applies the values after re-checking the name invariant (Category.cs:172; MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:73), and only then is CategoryItemChanged with DomainEntityState.Updated queued (Category.cs:176).
  • Why it's built this way: keeping the handler declarative means every rule that could reject the edit is discoverable in one place, the aggregate, and the sequence that must never vary (tracked load, not-found, concurrency stamp, mutate, save once) is written once in the framework instead of copy-pasted per slice. The concurrency stamp is the clearest example of why that matters: SetOriginalRowVersion is applied by the base for any command that reports a token, which is how a conditional endpoint gets 412 Precondition Failed rather than last-write-wins (ADR-035, MutateEntityHandlerBase.cs:285-292).
  • Where it's used: resolved by the CategoryItemsController as ICommandHandler<UpdateCategoryItemCommand, Result> (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:66) and invoked at CategoryItemsController.cs:161-167, followed by an explicit eviction of the conference:categories and conference output-cache tags and a 204 No Content (CategoryItemsController.cs:174-175). Covered by UpdateCategoryItemHandlerTests (MMCA.ADC.Conference.Application.Tests/Categories/UseCases/UpdateCategoryItemHandlerTests.cs:11).

RemoveCategoryItemHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.RemoveCategoryItem · MMCA.ADC.Conference.Application/Categories/UseCases/RemoveCategoryItem/RemoveCategoryItemHandler.cs:13 · Level 11 · class (sealed partial)

  • What it is: the handler for RemoveCategoryItemCommand. It is UpdateCategoryItemHandler plus one extra override: it can find its aggregate two different ways, because the DELETE endpoint does not guarantee the owning category id.
  • Depends on: RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType> from MMCA.Common.Application.UseCases (RemoveCategoryItemHandler.cs:4,16), IUnitOfWork (RemoveCategoryItemHandler.cs:3,14), IRepository<TEntity, TIdentifierType> as the LoadAsync parameter (RemoveCategoryItemHandler.cs:26), ILogger<T> with a source-generated [LoggerMessage] (RemoveCategoryItemHandler.cs:1,15,62-63), the Category aggregate and its CategoryItem child (RemoveCategoryItemHandler.cs:2), and Result (RemoveCategoryItemHandler.cs:5).
  • Concept introduced, resolving the aggregate from the child when the caller does not name it. The base it extends is a two-line specialization of the bare-Result mutate base whose only job is to make Includes abstract instead of virtual, because a remove that cannot see the child collection cannot find the child and reports a misleading NotFound (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:129-152). The interesting part is the LoadAsync override. The usual child-mutation handler loads by aggregate id and stops there; this one cannot assume it has an aggregate id, because the DELETE endpoint takes categoryId as a plain [FromQuery] argument (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:182) and the UI's generic delete sends only the item id, so CategoryId arrives as default (0). The handler branches on that (RemoveCategoryItemHandler.cs:35): when the id is unset it calls repository.FirstOrDefaultAsync with a predicate that finds the owner through its children, c => c.CategoryItems.Any(ci => ci.Id == command.CategoryItemId) (RemoveCategoryItemHandler.cs:37-41); otherwise it loads by id directly (RemoveCategoryItemHandler.cs:44-48). Both branches pass includes: Includes and asTracking: AsTracking, and both are load-bearing: the predicate overload of FirstOrDefaultAsync defaults asTracking to false (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:133-138), so an untracked load would make the change on a detached graph and lose it silently at SaveChangesAsync. [Rubric §4, Domain-Driven Design] assesses whether children are reached through their root: even the id-less path resolves the root first and then asks it to remove the child. [Rubric §12, Performance & Scalability]: the fallback path is a filter-over-children query rather than a keyed lookup, which is the cost of accepting a request that omits the owner; it is one row from the database rather than a materialized set, which is exactly what FirstOrDefaultAsync exists to guarantee (IRepository.cs:112-121). [Rubric §13, Observability & Operability]: the source-generated log message records both identifiers with structured fields and allocates nothing when the level is disabled.
  • Walkthrough: primary-constructor injection of IUnitOfWork and ILogger<RemoveCategoryItemHandler>, forwarded to the base (RemoveCategoryItemHandler.cs:13-16). Includes => [nameof(Category.CategoryItems)] satisfies the base's abstract member (RemoveCategoryItemHandler.cs:19), EntityId(command) => command.CategoryId supplies the key for the by-id branch (RemoveCategoryItemHandler.cs:22), and the LoadAsync override holds the two-branch lookup described above with an ArgumentNullException.ThrowIfNull(repository) guard first (RemoveCategoryItemHandler.cs:25-49). MutateAsync is again one line, Task.FromResult(entity.RemoveCategoryItem(command.CategoryItemId)) (RemoveCategoryItemHandler.cs:52-56), and LogMutated calls the generated LogCategoryItemRemoved partial (RemoveCategoryItemHandler.cs:59-60, template at :62-63). Everything else is the inherited sequence: not-found becomes Error.NotFound.WithSource(HandlerName).WithTarget(typeof(TEntity).Name) (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:282-283), which means the fallback branch reports a missing category even when what the caller actually got wrong was the item id; a domain failure is returned unchanged, so a missing-child failure from RemoveChildOrNotFound (MMCA.ADC.Conference.Domain/Categories/Category.cs:188-191) reaches the caller with its own error intact; and only a successful mutation reaches the single SaveChangesAsync (MutateEntityHandlerBase.cs:294-303). Inside the aggregate the removal is a soft delete on the child performed by the framework's RemoveChildOrNotFound helper, followed by a CategoryItemChanged event with DomainEntityState.Deleted (Category.cs:188-194).
  • Why it's built this way: the save-only-on-success shape is the framework's canonical command body, so a rejected removal writes nothing at all and the single SaveChangesAsync stays the one boundary that stamps audit fields, captures domain events, and writes the outbox row (ADR-003). The owner-resolution fallback exists because the UI reuses one generic delete action for every child grid, and the alternative would have been a bespoke client call per child type. Making it a LoadAsync override rather than a bespoke handler body is what keeps the rest of the sequence identical to every other mutation, which is the case the base's remarks explicitly call out (ChildEntityHandlerBase.cs:135-139).
  • Where it's used: resolved by the CategoryItemsController as ICommandHandler<RemoveCategoryItemCommand, Result> (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:67) and invoked at CategoryItemsController.cs:185-187, after which the controller evicts the conference:categories and conference output-cache tags and returns 204 No Content (CategoryItemsController.cs:194-195). Covered by RemoveCategoryItemHandlerTests (MMCA.ADC.Conference.Application.Tests/Categories/UseCases/RemoveCategoryItemHandlerTests.cs:11).

AddEventQuestionAnswerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerCommand.cs:11 · Level 8 · record (sealed)

  • What it is: the write intent for recording one attendee answer to an event-level Question, which is how post-event feedback reaches the Conference module. It names the event, the question, the answer text, and an optional explicit id for the child row.
  • Depends on: ICacheInvalidating from MMCA.Common.Application.UseCases (AddEventQuestionAnswerCommand.cs:2,15), the Event domain type used only to build the cache prefix (:1,18), and three identifier aliases, EventIdentifierType, EventQuestionAnswerIdentifierType, and QuestionIdentifierType (all = int, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8,9,11; see the primer on identifier aliases).
  • Concept: the optional-identity add command taught by AddCategoryItemCommand, applied to the one add-child command in this family that carries free text. EventQuestionAnswerIdentifierType? EventQuestionAnswerId (AddEventQuestionAnswerCommand.cs:13) is nullable for the same reason: the domain factory declares EventQuestionAnswer.Create(EventQuestionAnswerIdentifierType? id, ...) (MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:46-49) and resolves it as Id = isIdValueGenerated ? default : id!.Value (EventQuestionAnswer.cs:60). Because the entity carries [IdValueGenerated] (EventQuestionAnswer.cs:12), a null id becomes 0 and EF assigns the key at save, so the null branch is the only one the HTTP path ever takes. What makes this command different from its siblings is string AnswerValue (:14): it is caller-typed content, which is why it is the only add command in this family with a validator that has a rule body of its own (AddEventQuestionAnswerCommandValidator). [Rubric §9, API and Contract Design] assesses whether an inbound contract states its shape and optionality explicitly: three non-nullable fields plus one nullable id is the endpoint's own documentation of what a caller must supply. [Rubric §12, Performance & Scalability]: eviction is declared, not coded, because the caching decorator reads CachePrefix off the command rather than the handler purging by hand.
  • Walkthrough: a sealed record with four positional parameters (AddEventQuestionAnswerCommand.cs:11-15) implementing ICacheInvalidating (:15). The single body member is CachePrefix => $"{typeof(Event).FullName}:" (:18), keyed on the aggregate root and not on the child, because a cached event read carries its answers inline. Note what the command does not carry: no user id. The answering attendee is not a wire field, it is resolved server-side from ICurrentUserService inside AddEventQuestionAnswerHandler (AddEventQuestionAnswerHandler.cs:50), so no caller can post feedback in another attendee's name by shaping a body.
  • Why it's built this way: the command names exactly the values the aggregate method Event.AddEventQuestionAnswer needs (MMCA.ADC.Conference.Domain/Events/Event.cs:619-622) and nothing else, and it deliberately omits identity so that authorship is a server fact rather than a request field.
  • Where it's used: constructed by the event-question-answers controller as new AddEventQuestionAnswerCommand(request.EventId, null, request.QuestionId, request.AnswerValue) from an AddEventQuestionAnswerRequest body (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:146,150, handler injected at :57), validated by AddEventQuestionAnswerCommandValidator, and handled by AddEventQuestionAnswerHandler. The controller always passes null for the id, so the explicit-id parameter has no HTTP caller today.

AddEventSpeakerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.AddEventSpeaker · MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerCommand.cs:10 · Level 8 · record (sealed)

  • What it is: the write intent for associating a speaker with an event, that is, for creating one EventSpeaker join row under the Event aggregate.
  • Depends on: ICacheInvalidating (AddEventSpeakerCommand.cs:2,13), the Event type for the cache prefix (:1,16), and the aliases EventIdentifierType (= int), EventSpeakerIdentifierType (= int), and SpeakerIdentifierType (= System.Guid, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8,10,19).
  • Concept: the add-join command, the narrowest add shape in the module. A join row has no organizer-entered payload of its own, so the command is three identifiers and nothing more (AddEventSpeakerCommand.cs:10-13). The mixed key types are worth noticing: the parent is an int and the speaker is a Guid, because speakers carry Sessionize-assigned GUIDs (BR-61, noted at MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3) while the join row itself is database-generated ([IdValueGenerated] on MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:13). That single alias file is why the difference costs the command nothing: the type is stated once and every layer follows it. [Rubric §4, Domain-Driven Design] assesses whether children are created through their root: the command names the parent id first, so the only expressible operation is "add this speaker to that event".
  • Walkthrough: three positional parameters, EventId, the optional EventSpeakerId, and SpeakerId (AddEventSpeakerCommand.cs:10-13), plus CachePrefix => $"{typeof(Event).FullName}:" (:16). Duplicate protection is not on this record and not in its validator: the aggregate refuses a second live association with Error.Invariant("Event.Speaker.Duplicate", ...) (MMCA.ADC.Conference.Domain/Events/Event.cs:540-547), which is a rule that needs the sibling collection and therefore cannot live at the boundary.
  • Why it's built this way: keeping the join command to identifiers means the endpoint contract, the validator target, and the aggregate call signature stay in one-to-one correspondence, and it leaves the "already associated" decision with the only object that can see the existing associations.
  • Where it's used: constructed by the event-speakers controller as new AddEventSpeakerCommand(request.EventId, null, request.SpeakerId) from an AddEventSpeakerRequest body (MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:165,169, handler injected at :49), validated by AddEventSpeakerCommandValidator, and handled by AddEventSpeakerHandler.

AddRoomCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.AddRoom · MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommand.cs:15 · Level 8 · record (sealed)

  • What it is: the write intent for adding one Room to an event: the owning event, an optional explicit room id, and the six fields a room actually holds.
  • Depends on: ICacheInvalidating (AddRoomCommand.cs:2,23), the Event type for the cache prefix (:1,26), and the EventIdentifierType / RoomIdentifierType aliases (both = int).
  • Concept introduced, the nullable id that means "compute one", not "let the database assign one". Every other optional-identity add in this group relies on the store to generate the key, because the child carries [IdValueGenerated]. Room does not: it is declared without that attribute (MMCA.ADC.Conference.Domain/Events/Room.cs:13) and its doc comment states the reason, room ids are Sessionize-assigned rather than database-generated (Room.cs:8-11). The integer primary key is the upstream identifier, which is what lets the Sessionize import call @event.AddRoom(sr.Id, sr.Name, sr.Sort) with a foreign id (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:112). So when an organizer creates a room by hand there is no database sequence to fall back on, and RoomIdentifierType? RoomId (AddRoomCommand.cs:17) becomes an instruction to AddRoomHandler to allocate an id out of a reserved range that cannot collide with Sessionize's. Read this record together with that handler: the nullable parameter here is the whole reason the handler carries a retry loop. [Rubric §8, Data Architecture] assesses whether key ownership is deliberate: an app-assigned key shared with an external system is a real design choice, and its cost (allocation plus collision handling) is paid explicitly rather than hidden. [Rubric §9, API and Contract Design]: two required fields (Name, Sort) and four nullable ones (Capacity, Floor, Location, AccessibilityInfo, :20-23) are the endpoint's optionality contract.
  • Walkthrough: eight positional parameters (AddRoomCommand.cs:15-23), implementing ICacheInvalidating (:23), with CachePrefix => $"{typeof(Event).FullName}:" (:26) keyed on the aggregate root rather than on Room. Name and Sort are non-nullable, so the command cannot express "leave the name alone", which is correct for an add. Name uniqueness within the event is not here and not in the validator: the aggregate enforces it before creating the room (MMCA.ADC.Conference.Domain/Events/Event.cs:394-396), backed by the IX_Room_EventId_Name index the handler names by hand (AddRoomHandler.cs:37).
  • Why it's built this way: the eight parameters are exactly the arguments of Event.AddRoom (MMCA.ADC.Conference.Domain/Events/Event.cs:385-392), so nothing is dropped or invented between the wire and the aggregate, and the optional id keeps one aggregate method serving both the organizer path and the importer path.
  • Where it's used: constructed by the rooms controller from an AddRoomRequest body (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:207,211, handler injected at :94), validated by AddRoomCommandValidator, and handled by AddRoomHandler. Its edit and delete counterparts are UpdateRoomCommand and RemoveRoomCommand.

EventCreateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:11 · Level 8 · record (class)

  • What it is: the inbound contract for creating a conference Event, the module's top aggregate. As with every create slice in this module it is both the HTTP request body and the command the CQRS pipeline dispatches: there is no separate CreateEventCommand.
  • Depends on: ICreateRequest and ICacheInvalidating from MMCA.Common.Application.Interfaces and .UseCases (EventCreateRequest.cs:3-4,11), the module-local IEventFieldsRequest (:1,11), the Event type for the cache prefix (:2,14), the EventIdentifierType alias (= int), and System.DateOnly (BCL, :26,29).
  • Concept introduced, the shared-field interface that keeps create and update honest. This record implements three interfaces, and the third is the interesting one. IEventFieldsRequest (MMCA.ADC.Conference.Application/Events/Validation/IEventFieldsRequest.cs:12-34) declares the seven event fields that create and update validate identically. A FluentValidation validator is generic over the type it validates, so a rule written for the create request cannot normally be reused by the update request. Declaring the shared shape as an interface lets EventFieldRules<T> be constrained where T : IEventFieldsRequest (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:139-141) and written once, so the two operations cannot drift apart on what a valid event name, time zone, date range, contact email, sponsorship URL, or ticketing URL is. What is deliberately off the interface is as instructive as what is on it: the live-layer moderation default is carried only by the update request, and its rule lives in that operation's validator (IEventFieldsRequest.cs:8-11). [Rubric §15, Best Practices & Code Quality] assesses whether a change has one edit point: adding a shared event field means one interface member and one line in EventFieldRules<T>, and both operations follow. [Rubric §9, API and Contract Design]: exactly four members are required (Name, StartDate, EndDate, TimeZone, :20,26,29,32) and nine are optional by declaration. [Rubric §12, Performance & Scalability]: eviction is declared through CachePrefix, not coded in the handler.
  • Walkthrough: CachePrefix => $"{typeof(Event).FullName}:" (EventCreateRequest.cs:14) is the key the caching decorator purges on success. Id (:17) is a non-nullable EventIdentifierType, so an omitted id binds to 0; that is harmless here because Event carries [IdValueGenerated] (MMCA.ADC.Conference.Domain/Events/Event.cs:23) and the factory resolves the key as Id = isIdValueGenerated ? default : id!.Value (Event.cs:203), which means the factory's id!.Value branch is unreachable for this aggregate. The four required members (:20,26,29,32) are the ones the domain factory guards, EnsureNameIsValid, EnsureTimeZoneIsValid, and EnsureDateRangeIsValid (Event.cs:180-183). The nine optional members are the edition's publishable extras: Description, SessionizeCode, VenueAddress, VenueMapUrl, WiFiInfo, OrganizerContactEmail, SponsorshipPacketUrl, and TicketingUrl (:23,35,38,41,44,47,50,53). Every member is init-only, so model binding is the last chance to set anything. Note the type declaration is public record class rather than sealed record (:11), matching its sibling create requests in this module.
  • Why it's built this way: collapsing request and command removes a mapping step with no behavior of its own, and pushing the shared field list onto an interface keeps the create and update validators from being two copies of the same rules that can silently diverge.
  • Where it's used: bound by the events controller (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:204), which types its base class on it (EventsController.cs:59) and injects the handler as ICommandHandler<EventCreateRequest, Result<EventDTO>> (:48); validated by EventCreateRequestValidator, converted by EventCreateRequestMapper, and handled by CreateEventHandler. Its edit-side counterpart is EventUpdateRequest.

PublishEventCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Publish · MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventCommand.cs:13 · Level 8 · record (sealed)

  • What it is: the write intent for the publish transition on an Event, the flip that makes an edition visible to attendees. It carries the event id and the caller's optimistic-concurrency token, and nothing else.
  • Depends on: ICacheInvalidating (PublishEventCommand.cs:2,13), the Event type for the cache prefix (:1,16), the EventIdentifierType alias (= int), and byte[] (BCL).
  • Concept introduced, the conditional state-transition command. Most commands in this module carry payload. This one carries a precondition. byte[] RowVersion (:13) is non-nullable and its doc comment states the contract in the code itself: it is the caller's last-observed token, read from the request's If-Match header, and it is required because a transition that states no precondition never reaches this command (:8-12). The enforcement is split across three places worth following. The controller declares [SupportsIfMatch] so a request without the header answers 428 Precondition Required and never runs the action (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:265), and pulls the token with SupportsIfMatchAttribute.RequiredToken(HttpContext) (:272). PublishEventHandler reports it back to the framework through its RowVersion override (PublishEventHandler.cs:25). The framework's write workflow stamps it as the entity's original version, repository.SetOriginalRowVersion(entity, rowVersion) (MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:292), so a decision made against a stale view fails the save and answers 412 Precondition Failed instead of last-write-wins. That is ADR-035. [Rubric §9, API and Contract Design] assesses whether a mutating endpoint states its concurrency contract: here the precondition is a required field of the command type, not a convention. [Rubric §29, Resilience and Business Continuity]: pairing the conditional token with [Idempotent] (EventsController.cs:263) means a retried publish replays the stored response rather than racing a second transition.
  • Walkthrough: a sealed record with two positional parameters (PublishEventCommand.cs:13) plus CachePrefix => $"{typeof(Event).FullName}:" (:16). There is no PublishEventCommandValidator, and that absence is asserted rather than accidental: the command is listed under "identifier-only commands" in the architecture fitness rule that requires every data-carrying command to have a validator (MMCA.ADC.Architecture.Tests/Cqrs/CommandValidatorCoverageTests.cs:32, the rule's reasoning at :26-30). Its whole payload is a route-supplied id plus a header-supplied token, so a validator would be ceremony. The "already published" refusal lives in the aggregate, Error.Invariant("Event.AlreadyPublished", ...) (MMCA.ADC.Conference.Domain/Events/Event.cs:301-308).
  • Why it's built this way: a verb command with no payload keeps the transition auditable and gives the pipeline a precise eviction boundary, while carrying the concurrency token as a required constructor parameter makes it impossible to construct the command without a stated precondition.
  • Where it's used: constructed by the events controller as new PublishEventCommand(id, rowVersion) (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:276, handler injected as ICommandHandler<PublishEventCommand, Result> at :50) and handled by PublishEventHandler. Its mirror is UnpublishEventCommand.

AddEventQuestionAnswerCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerCommandValidator.cs:8 · Level 9 · class (sealed)

  • What it is: the FluentValidation validator the pipeline runs against an AddEventQuestionAnswerCommand before the handler sees it. It is one rule.
  • Depends on: FluentValidation.AbstractValidator<T> (NuGet, AddEventQuestionAnswerCommandValidator.cs:1,8) and the command it validates.
  • Concept: the boundary-versus-domain split, seen at its sharpest. The validator checks that AnswerValue is non-empty and stops there (:11-14). Three further rules about the same field live elsewhere, on purpose. Max length (4000 characters) is a domain invariant applied by the entity factory (EventInvariants.EnsureAnswerValueIsValid, MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:140, with AnswerValueMaxLength at :58). Whether the answer's shape is legal depends on the question being answered, so EnsureAnswerValueMatchesQuestionType (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:118-129) switches on the question type and demands, for a "Rating", an integer between 1 and 5 (QuestionInvariants.cs:131-143). And whether the question even applies to events is a cross-aggregate lookup the handler performs (AddEventQuestionAnswerHandler.cs:64-74). None of the last three can be decided from the incoming values alone, which is exactly the criterion for what a boundary validator may hold. [Rubric §24, Forms, Validation and UX Safety] assesses whether invalid input is rejected at the boundary with actionable messages: the rule carries both human text and the stable machine code EventQuestionAnswer.AnswerValue.Required (:13-14), and a client may branch on the code while the wording changes freely.
  • Walkthrough: an expression-bodied constructor (AddEventQuestionAnswerCommandValidator.cs:10-14), RuleFor(x => x.AnswerValue).NotEmpty().WithMessage("Answer value is required.").WithErrorCode("EventQuestionAnswer.AnswerValue.Required"). Nothing is checked about EventQuestionAnswerId (there is nothing to validate about an absent id) or about EventId and QuestionId (a wrong id answers NotFound from the handler, not a field error).
  • Why it's built this way: keeping only the value-shape rule here means the validator has no dependencies and can be constructed with new in a unit test, while the rules that need loaded state stay where the state is.
  • Where it's used: resolved by the validating decorator around AddEventQuestionAnswerHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered directly by AddEventQuestionAnswerCommandValidatorTests (Group 27).

AddEventQuestionAnswerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.AddEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:19 · Level 9 · class (sealed partial)

  • What it is: the write handler for attendee event feedback. It loads the event, checks three business rules, upserts the attendee's answer, and publishes a cross-module notification so the Engagement module can award points.
  • Depends on: IUnitOfWork (AddEventQuestionAnswerHandler.cs:20), ICurrentUserService (:20), EventQuestionAnswerDTOMapper injected by concrete type (:21), TimeProvider (BCL, :22), ILogger<T> (:23), the Event and Question aggregates, EventInvariants and QuestionInvariants, the EventFeedbackSubmitted integration event (:6,112), and ICommandHandler<in TCommand, TResult> (:23).
  • Concept introduced, the handler that is hand-written because the workflow is not the generic one. Every other add-child handler in this unit subclasses a framework base (AddEventSpeakerHandler is the clean example). This one implements ICommandHandler<in TCommand, TResult> directly (:23) because its workflow branches: the same POST either adds a new answer or rewrites the caller's existing one (BR-107), which no "add child" base can express. The second idea it introduces is the cross-module notification raised on the aggregate before the save. entity.AddDomainEvent(new EventFeedbackSubmitted(userId, entity.Id, timeProvider.GetUtcNow().UtcDateTime)) (:112) does not send anything: it appends to the aggregate's pending list, and the outbox captures the message in the same SaveChangesAsync transaction that writes the answer row (ADR-003). Either both land or neither does, so Engagement can never award points for feedback that was rolled back. The event itself is a versioned contract, [EventName("Conference.EventFeedbackSubmitted.v1")] (MMCA.ADC.Conference.Shared/Events/IntegrationEvents/EventFeedbackSubmitted.cs:19-24). [Rubric §6, CQRS and Event-Driven] assesses whether modules communicate through durable, named events rather than direct calls: this handler names no Engagement type at all. [Rubric §7, Microservices Readiness]: the notification crosses a module boundary through the message contract in the Shared project, which is what makes the module extractable. [Rubric §11, Security]: authorship is taken from the authenticated principal (:50), never from the request body, and the controller is [Authorize] (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:54).
  • Walkthrough: HandleAsync (:26-58) runs five steps. (1) Load the event tracked, with the answers collection eager-loaded, includes: [nameof(Event.EventQuestionAnswers)], asTracking: true (:31-35); a missing event returns Error.NotFound tagged with the handler and entity names (:36-37). (2) BR-108, the event must be published, via EventInvariants.EnsureEventIsPublished (:40-42, invariant at MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:151-157). (3) ValidateQuestionAsync (:60-79) covers two rules in one pass: BR-128 loads the Question from its own repository and rejects it unless question.QuestionEntity == "Event" (:65-74), then BR-124 checks the answer against the question's type (:77-78). (4) BR-107, the upsert: the caller's identity comes from currentUserService.UserId!.Value (:50) and the existing answer is found in memory with FirstOrDefault(a => !a.IsDeleted && a.QuestionId == command.QuestionId && a.CreatedBy == userId) (:51-52), which is why the include in step 1 is load-bearing. (5) The branch: UpdateExistingAnswerAsync (:81-94) calls entity.UpdateEventQuestionAnswer, saves, logs, and maps the existing child; CreateNewAnswerAsync (:96-117) calls entity.AddEventQuestionAnswer, raises the integration event, saves, logs, and maps the new child. Note the asymmetry, stated in the code comment at :109-111: EventFeedbackSubmitted is raised on the create path only, so editing an answer does not award a second round of points. Logging goes through a source-generated [LoggerMessage] partial (:119-120), which is why the class is partial.
  • Why it's built this way: upsert-on-POST is the right contract for a feedback form (a re-submit is a correction, not a duplicate), and the only way to detect "the caller already answered this question" is against the loaded sibling collection, which places the rule in the handler rather than the boundary. Filtering on !a.IsDeleted (:52) rather than relying on the query filter reflects the soft-delete model (ADR-005): the collection was materialized through the aggregate, so the predicate is applied in memory.
  • Where it's used: resolved as ICommandHandler<AddEventQuestionAnswerCommand, Result<EventQuestionAnswerDTO>> by the event-question-answers controller (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:57,150); registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered by AddEventQuestionAnswerHandlerTests (Group 27).
  • Caveats: currentUserService.UserId!.Value (:50) uses the null-forgiving operator, so an unauthenticated invocation would throw rather than return a failure. The controller's [Authorize] (EventQuestionAnswersController.cs:54) is what makes that unreachable over HTTP; the handler itself states no such guard.

AddEventSpeakerCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.AddEventSpeaker · MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerCommandValidator.cs:8 · Level 9 · class (sealed)

  • What it is: the validator for AddEventSpeakerCommand. It asserts one thing: the speaker id is not the default value.
  • Depends on: FluentValidation.AbstractValidator<T> (NuGet, AddEventSpeakerCommandValidator.cs:1,8) and the SpeakerIdentifierType alias (= System.Guid).
  • Concept: the alias-aware default check. NotEqual(default(SpeakerIdentifierType)) (:12) reads as a type-agnostic rule, but it resolves to Guid.Empty because the alias is System.Guid (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19). Written this way, the rule keeps working if the alias is ever retyped, which is the point of routing every key through the alias file rather than naming Guid here. This matters more than for the int-keyed commands: an omitted Guid binds to Guid.Empty rather than to a value that would obviously fail a foreign-key lookup, so without this rule an empty speaker id would reach the aggregate and surface as a persistence error rather than a field-addressed 400.
  • Walkthrough: an expression-bodied constructor (AddEventSpeakerCommandValidator.cs:10-13) with RuleFor(x => x.SpeakerId).NotEqual(default(SpeakerIdentifierType)).WithMessage("Speaker ID is required."). Note what is missing relative to its sibling in this unit: there is no .WithErrorCode(...) call, so this rule reports FluentValidation's default code (NotEqualValidator) where AddEventQuestionAnswerCommandValidator reports the stable EventQuestionAnswer.AnswerValue.Required (AddEventQuestionAnswerCommandValidator.cs:14). A client branching on error codes gets a module-specific contract from one and a library-default one from the other. Duplicate association is not checked here: it needs the sibling collection, so the aggregate owns it (MMCA.ADC.Conference.Domain/Events/Event.cs:538-546).
  • Why it's built this way: the join command carries only identifiers, so the single field a boundary validator can meaningfully reject is an unset speaker id.
  • Where it's used: resolved by the validating decorator around AddEventSpeakerHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered by AddEventSpeakerCommandValidatorTests (Group 27).

AddEventSpeakerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.AddEventSpeaker · MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:15 · Level 9 · class (sealed partial)

  • What it is: the write handler that associates a speaker with an event. It contains no workflow of its own: it fills in five hooks on a framework base class.
  • Depends on: AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO> from MMCA.Common.Application.UseCases (AddEventSpeakerHandler.cs:19), IUnitOfWork (:16), EventSpeakerDTOMapper injected by concrete type (:17), and ILogger<T> (:18).
  • Concept introduced, the add-child handler as five answers. The framework owns the shape every add-child write repeats: resolve the parent repository, load the parent with the declared includes and tracking, return NotFound if it is absent, call the aggregate method, save, log, run the post-commit hook, and map the child (MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:102-126). A concrete handler supplies only what the framework cannot know: which collection to include, how to read the parent id off the command, which aggregate method to call, how to map the child, and what to log. Everything else, including the failure taxonomy, is inherited. Compare this thirty-line file with AddEventQuestionAnswerHandler's hundred and twenty: the base is used where the workflow fits and bypassed where it does not, rather than being contorted. [Rubric §2, Design Patterns] assesses whether a recurring shape is captured once: the template-method base is that capture. [Rubric §1, SOLID]: the five protected override members are the extension points, and the handler adds no public surface of its own. [Rubric §15, Best Practices & Code Quality]: a change to the write workflow (for example, an added post-commit hook) lands in the framework, not in each module's handlers.
  • Walkthrough: five overrides. Includes => [nameof(Event.EventSpeakers)] (AddEventSpeakerHandler.cs:24) is the load-bearing one, and its comment says why (:21-22): the base declares Includes abstract (ChildEntityHandlerBase.cs:55) precisely so that naming or deliberately not naming the join collection is an explicit decision, because an unloaded collection makes the aggregate's duplicate check run against an empty in-memory list and turns a double submit into a raw unique-index 409. ParentId reads command.EventId (:27). Apply calls parent.AddEventSpeaker(command.EventSpeakerId, command.SpeakerId) (:30-31), the aggregate method that owns the duplicate rule and raises EventSpeakerChanged (MMCA.ADC.Conference.Domain/Events/Event.cs:536,557). MapChild delegates to the Mapperly mapper (:34). LogAdded forwards to the source-generated [LoggerMessage] partial (:37-41), which is why the class is partial. Tracking is left at the base default of true (ChildEntityHandlerBase.cs:49), because a no-tracking load would make the add a silent no-op.
  • Why it's built this way: the base is an abstract class implementing ICommandHandler<in TCommand, TResult> rather than a decorator, so the concrete subclass stays the registered handler and the pipeline decorators wrap it normally.
  • Where it's used: resolved as ICommandHandler<AddEventSpeakerCommand, Result<EventSpeakerDTO>> by the event-speakers controller (MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:49,169); registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered by AddEventSpeakerHandlerTests (Group 27).

AddRoomCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.AddRoom · MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomCommandValidator.cs:7 · Level 9 · class (sealed)

  • What it is: the validator for AddRoomCommand. It holds no rule bodies: it is six Include calls, one per room field.
  • Depends on: FluentValidation.AbstractValidator<T> (NuGet, AddRoomCommandValidator.cs:1,7) and the six generic rule objects from MMCA.ADC.Conference.Application.Events.Validation (:2,11-16): RoomNameRules<T>, RoomSortRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T>, and RoomAccessibilityInfoRules<T>.
  • Concept: rule composition by Include with a property selector, the mechanism the module's rule objects introduce, at its widest use in this unit. Each rule is generic in T and takes an Expression<Func<T, ...>>, so new RoomNameRules<AddRoomCommand>(p => p.Name) means "apply the room-name rules to this type's Name property", and the add command and the update command include the same six objects rather than restating them. The limits all resolve back to one place, EventInvariants, which forwards them from the DTO constants: name 255 (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:47, MMCA.ADC.Conference.Shared/Rooms/RoomDTO.cs:16), floor 100 (EventInvariants.cs:49, RoomDTO.cs:19), location 255 (EventInvariants.cs:52, RoomDTO.cs:22), accessibility info 500 (EventInvariants.cs:55, RoomDTO.cs:25). Because the DTO constant is also what the EF configuration reads, the API error message and the column width cannot disagree. [Rubric §15, Best Practices & Code Quality] assesses whether a change has one edit point: raising a room limit is one edit to the DTO constant. [Rubric §24, Forms, Validation and UX Safety]: each rule attaches a stable machine code alongside its human message, for example Room.Name.Required and Room.Name.MaxLength (MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:18-19).
  • Walkthrough: a block-bodied constructor with six statements (AddRoomCommandValidator.cs:9-17). Two rules are required-field rules: RoomNameRules<T> derives from the framework's RequiredStringRules<T> and enforces non-empty plus max length (RoomValidationRules.cs:13-20), and RoomSortRules<T> derives from NonNegativeIntRules<T> with the code Room.Sort.Negative (:26-32). Four are conditional: RoomCapacityRules<T> applies GreaterThan(0) with code Room.Capacity.NotPositive only .When(...) the value is not null (:37-45), and the three optional strings derive from OptionalStringRules<T> with their own max-length codes (:51-80). Nothing here checks room-name uniqueness within the event: that needs the sibling collection, so the aggregate owns it (MMCA.ADC.Conference.Domain/Events/Event.cs:396-398).
  • Why it's built this way: field-shape rules that need only the incoming values run at the boundary where they can be reported as one complete, field-addressed list, and expressing them as reusable objects is what keeps the add path and the edit path from drifting on what a valid room looks like.
  • Where it's used: resolved by the validating decorator around AddRoomHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered by AddRoomCommandValidatorTests (Group 27).

EventCreateRequestMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestMapper.cs:11 · Level 9 · class (sealed)

  • What it is: the one adapter between the wire contract EventCreateRequest and the Event domain entity. It does not construct the entity itself: it calls the aggregate's factory and hands back whatever Result<T> that factory returns.
  • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> from MMCA.Common.Application.Interfaces (EventCreateRequestMapper.cs:2,12), the Event aggregate (:1), Result (:3), and the EventIdentifierType alias.
  • Concept introduced, the write-side mapper as a factory call. The read-side mappers in this module are Mapperly-generated property copies (ADR-001). The write side deliberately is not. Generating a property copy into an entity would have to write private setters and would bypass the guard clauses, so this mapper is hand-written and its entire body is a call to Event.Create (:19-32). That keeps a single door into the aggregate: a new event exists only if EnsureNameIsValid, EnsureTimeZoneIsValid, and EnsureDateRangeIsValid all pass (MMCA.ADC.Conference.Domain/Events/Event.cs:195-198), and the failure comes back as errors rather than a half-built object. [Rubric §4, Domain-Driven Design] assesses whether the aggregate controls its own construction: the Application layer holds no new Event(...). [Rubric §3, Clean Architecture]: the dependency points inward, the mapper knows the domain and the domain knows nothing of the request type. [Rubric §14, Testability]: the mapper is a pure function of its input and can be exercised without a database.
  • Walkthrough: one method, CreateEntityAsync (EventCreateRequestMapper.cs:15-33). It null-guards with ArgumentNullException.ThrowIfNull(request) (:17), then returns Task.FromResult(Event.Create(...)) (:19-32): the work is synchronous, and the Task exists only because the interface is async for the mappers that do need I/O. Thirteen arguments are forwarded positionally except the last three, which are passed by name (organizerContactEmail, sponsorshipPacketUrl, ticketingUrl, :30-32). One factory parameter is deliberately not forwarded: questionModerationDefault, which keeps its declared default of QuestionModerationDefault.Pending (MMCA.ADC.Conference.Domain/Events/Event.cs:181). That is the same asymmetry IEventFieldsRequest records: the moderation default is an update-time concern, so a create never states it and every new event starts on the safe setting.
  • Why it's built this way: routing creation through the factory is what makes the invariants unskippable, and delegating rather than copying means adding a domain guard automatically covers the HTTP path with no change here.
  • Where it's used: injected into CreateEventHandler as IEntityRequestMapper<Event, EventCreateRequest, EventIdentifierType> (MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:17) and consumed by the framework's create workflow at requestMapper.CreateEntityAsync(request, cancellationToken) (MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:90). Registration is by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133), not an explicit AddScoped line.

EventCreateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequestValidator.cs:7 · Level 9 · class (sealed)

  • What it is: the validator the pipeline runs against EventCreateRequest. It is a single Include.
  • Depends on: FluentValidation.AbstractValidator<T> (NuGet, EventCreateRequestValidator.cs:1,7) and EventFieldRules<T> (:2,10).
  • Concept: rule composition through the shared-field interface introduced by EventCreateRequest. Because EventFieldRules<T> is constrained where T : IEventFieldsRequest and reaches its fields through the interface (MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:139-151), this validator needs no property selectors at all: the interface already names the properties. That is the difference from the room and category validators, which pass a lambda per field. [Rubric §15, Best Practices & Code Quality] assesses whether a change has one edit point: create and update share one rule list, so a new event field is validated identically on both paths or on neither.
  • Walkthrough: an expression-bodied constructor, Include(new EventFieldRules<EventCreateRequest>()) (EventCreateRequestValidator.cs:9-10). What that pulls in is six rule sets (EventValidationRules.cs:145-150): name required plus max length 500 via RequiredStringRules<T> (:13-18, limit at MMCA.ADC.Conference.Shared/Events/EventDTO.cs:19); time zone required, max length 100, and a Must(BeAValidIanaTimeZone) check that resolves the identifier through TimeZoneInfo.FindSystemTimeZoneById and reports Event.TimeZone.InvalidIana on a TimeZoneNotFoundException (:25-48, BR-87); a date-range rule requiring both dates and asserting endDate >= startDate with code Event.EndDate.BeforeStart (:113-131); and three conditional rules for the optional organizer email, sponsorship packet URL, and ticketing URL, each applying the framework's shared email or absolute-URL rules only When a value is present (:57-67,77-87,100-107). The URL rules check the scheme, which is what keeps a rendered link from carrying an executable javascript: or data: target (:69-75).
  • Why it's built this way: writing the shared rules once against an interface and leaving each concrete validator to add only its own delta is what makes "create and update agree" a compile-time property rather than a review convention.
  • Where it's used: resolved by the validating decorator around CreateEventHandler, registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered by EventCreateRequestValidatorTests (Group 27, MMCA.ADC.Conference.Application.Tests/Events/Validation/EventCreateRequestValidatorTests.cs). Its edit-side counterpart is EventUpdateRequestValidator.

CreateEventHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Create · MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:15 · Level 10 · class (sealed partial)

  • What it is: the handler for creating an Event. Its body is one line: the framework base owns the whole create workflow, and this class supplies only the log message.
  • Depends on: CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> (CreateEventHandler.cs:20-21), IUnitOfWork (:16), EventCreateRequestMapper resolved through its interface (:17), EventDTOMapper injected by concrete type (:18), and ILogger<T> (:19).
  • Concept: the create counterpart of the template-method base AddEventSpeakerHandler demonstrates. CreateCoreAsync runs the fixed sequence (MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:77-103): the optional PrepareAsync pre-map step, requestMapper.CreateEntityAsync (:89), PersistAsync which adds and saves (:96,128-145), LogCreated (:98), the OnCreatedAsync post-commit hook (:99), and finally dtoMapper.MapToDTO(entity) (:101). Three of those five hooks are virtual with working defaults, so a create slice with no special needs overrides exactly one. Note the generic constraints (CreateEntityHandlerBase.cs:46-49): the request must implement ICreateRequest, the entity must be an AuditableAggregateRootEntity<TIdentifierType>, and the DTO must implement IBaseDTO<TIdentifierType>, so the workflow is only reusable where the shape genuinely matches. [Rubric §2, Design Patterns] assesses whether a recurring shape is captured once. [Rubric §13, Observability and Operability]: LogCreated is a no-op in the base by design (CreateEntityHandlerBase.cs:147-150), because a log message is per-module vocabulary and the base only provides the call site.
  • Walkthrough: the primary constructor forwards unitOfWork, requestMapper, and dtoMapper to the base (CreateEventHandler.cs:15-21). The only member is LogCreated, which forwards to the source-generated [LoggerMessage] partial LogEventCreated with the id and name (:24-27), so the emitted event is structured and allocation-free rather than an interpolated string. PrepareAsync and PersistAsync are left at their defaults: an event needs no app-assigned key (unlike AddRoomHandler) and no non-standard persist step. Audit stamping and soft-delete are not visible here at all; they are applied by ApplicationDbContext.SaveChangesAsync (see the primer).
  • Why it's built this way: a create path with nothing unusual about it should be a declaration of its three collaborators plus a log message, and the fact that this file is 28 lines is the evidence that the framework absorbed the rest.
  • Where it's used: resolved as ICommandHandler<EventCreateRequest, Result<EventDTO>> by the events controller, which passes it to its base action (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:49,60,204); registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered by CreateEventHandlerTests (Group 27).

PublishEventHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Publish · MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventHandler.cs:13 · Level 10 · class (sealed partial)

  • What it is: the handler for the publish transition. Like its create sibling it is four overrides on a framework base, and the interesting one is the concurrency token.
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> (PublishEventHandler.cs:16), IUnitOfWork (:14), ILogger<T> (:15), and the Event aggregate.
  • Concept introduced, the verb-command handler that answers with a bare Result. The three-parameter MutateEntityHandlerBase is the base for commands where the caller needs only success or the refused invariant, which is exactly what publish, unpublish, open, close, and remove-a-child are (MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:320-331). Its shared workflow, MutateCoreAsync (:270-308), resolves the repository, loads the aggregate, returns Error.NotFound if it is absent (:279-282), stamps the optimistic-concurrency token if the handler reports one (:290-291), runs MutateAsync, short-circuits without saving if the mutation marked the run as a no-op (:299-300), saves, logs, and runs the post-commit hook. The conditional stamp is the mechanism behind ADR-035: a handler serving an unconditional endpoint reports null from the base default (:90) and the stamp is skipped, while a handler on a conditional endpoint always has a token, because a request without an If-Match header never reaches the action. [Rubric §9, API and Contract Design] assesses whether concurrency is part of the contract rather than an afterthought. [Rubric §29, Resilience and Business Continuity]: a stale write is refused with 412 instead of silently overwriting a concurrent edit.
  • Walkthrough: four overrides. EntityId reads command.Id (PublishEventHandler.cs:19). RowVersion returns command.RowVersion (:25), the single line that turns this into a conditional write, with the reason stated in the comment above it (:21-22). MutateAsync returns Task.FromResult(entity.Publish()) (:28-32), delegating the entire decision to the aggregate, which refuses a second publish with Event.AlreadyPublished and otherwise sets IsPublished and raises EventChanged (MMCA.ADC.Conference.Domain/Events/Event.cs:299-315). LogMutated forwards to the [LoggerMessage] partial (:35-39). Includes is left at the base default of empty (MutateEntityHandlerBase.cs:69), because publishing touches no child collection.
  • Why it's built this way: keeping the transition rule in the aggregate and the concurrency policy in the framework leaves the handler with nothing to hold but the four bindings, which is what makes an audit of "which endpoints are conditional" a search for RowVersion overrides.
  • Where it's used: resolved as ICommandHandler<PublishEventCommand, Result> by the events controller (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:51,276), which evicts the conference:events output-cache tag after a success (:282); registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered by PublishEventHandlerTests (Group 27). Its mirror is UnpublishEventHandler.

AddRoomHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.AddRoom · MMCA.ADC.Conference.Application/Events/UseCases/AddRoom/AddRoomHandler.cs:23 · Level 15 · class (sealed partial)

  • What it is: the handler that adds a Room to an event. It is the most involved write handler in this unit, because room primary keys are application-assigned: when the organizer supplies no id, this handler allocates one, and it retries in a fresh DI scope when a concurrent add wins the same id.
  • Depends on: MutateEntityPayloadHandlerBase<TCommand, TEntity, TIdentifierType, TResultPayload> (AddRoomHandler.cs:28), IUnitOfWork (:24), IServiceScopeFactory from Microsoft.Extensions.DependencyInjection (:1,25), RoomDTOMapper injected by concrete type (:26), ILogger<T> (:27), MutationContext, IEntityQuerier<TEntity, TIdentifierType> (:128), and EventInvariants for the reserved id range.
  • Concept introduced, the application-assigned key and the retry that it forces. Follow the chain from AddRoomCommand: Room has no [IdValueGenerated] attribute, because its integer key is the Sessionize room id (MMCA.ADC.Conference.Domain/Events/Room.cs:8-13). There is therefore no database sequence to fall back on for an organizer-created room, and something in the application has to pick a number that Sessionize will never pick. The answer is a reserved band: RoomManualIdRangeStart = 999_999_000 and RoomManualIdRangeEnd = 999_999_999 (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:66,69), one thousand ids allocated globally across all events. Allocation is read-then-write ("take the max in the band, add one", AddRoomHandler.cs:136-138), which is a race by construction, so the handler pairs it with a bounded retry. The second idea is the attempt-scoped unit of work. A retry cannot reuse the ambient DbContext: it still tracks the failed insert, so a recomputed id would never persist. The framework anticipated this by taking the unit of work as a parameter of MutateCoreAsync (MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:271-276), which is what makes "retry in a fresh scope" a parameter rather than a second copy of the workflow, as the class remarks say (AddRoomHandler.cs:18-22). [Rubric §8, Data Architecture] assesses whether key generation is deliberate and collision-safe: a reserved, exhaustion-checked band with an explicit retry is that answer stated in code. [Rubric §29, Resilience and Business Continuity] assesses whether a foreseeable race is handled rather than surfaced: a lost id race becomes a warning and a retry, not a 500. [Rubric §12, Performance and Scalability]: the allocation query is a filtered, ignoreQueryFilters read over the reserved band only (:130-134), so it never scans the full room table.
  • Walkthrough (fields, then the loop, then the hooks). Four const values carry the policy: MaxManualIdAttempts = 3 (:31), RoomNameIndexName = "IX_Room_EventId_Name" (:37), and two context keys, AttemptUnitOfWorkKey and AddedRoomKey (:44,47), each with a comment stating why it exists. EntityId reads command.EventId (:50) and Includes => [nameof(Event.Rooms)] (:56) is load-bearing for the same reason as in AddEventSpeakerHandler: without it the aggregate's duplicate-name check runs against an empty list. HandleAsync (:59-92) is overridden rather than inherited. An explicit caller id short-circuits to a single attempt with no recomputation, because a collision there is a genuine caller error (:63-66). Otherwise a while (true) loop runs attempt 1 against the injected unit of work (:74-75) and every later attempt inside await using var scope = scopeFactory.CreateAsyncScope() (:82-84), re-running the whole workflow including the event load, since reusing the previous attempt's entity would append a second room and re-raise RoomChanged (:77-81). The catch filter is narrow: when (attempt < MaxManualIdAttempts && IsUniqueKeyViolation(ex)) (:86). AddRoomAttemptAsync (:98-111) creates the MutationContext, stashes the attempt's unit of work in it, calls MutateCoreAsync, and shapes the DTO from what the mutation recorded. MutateAsync (:114-160) reads the attempt's unit of work back out of the context (:120, defaulting to the injected one), allocates the id when command.RoomId is null (:126-144) and fails with Room.ManualIdRangeExhausted if the band is full (:140-141), calls entity.AddRoom(...) (:146-153), and stores the new room under AddedRoomKey (:157). BuildResult maps that stored room to a RoomDTO (:167-168). IsUniqueKeyViolation (:178-190) walks the whole InnerException chain looking for "duplicate key" while excluding any message naming the room-name index, and the reason is spelled out at :170-177: the Application layer cannot reference EF Core types, so detection is message-based, and a name conflict must not be retried because recomputing an id would never clear it. Two [LoggerMessage] partials close the file, an information-level "room added" and a warning-level collision notice (:192-196).
  • Why it's built this way: the retry lives in the handler and not in the framework because the thing being recomputed (the next id in a reserved band) is domain knowledge; the framework contributes only the attempt-scoped workflow that makes re-running it clean. The bound of three attempts is a deliberate ceiling: after that the caller gets the conflict rather than an unbounded loop.
  • Where it's used: resolved as ICommandHandler<AddRoomCommand, Result<RoomDTO>> by the rooms controller (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:94,211), whose POST action is marked [Idempotent] so a retried request replays the first response instead of adding a second row (RoomsController.cs:205); registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered by AddRoomHandlerTests (Group 27). The Sessionize import does not go through this handler: RoomSyncStrategy calls the aggregate directly with the upstream id (MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/RoomSyncStrategy.cs:112).
  • Caveats: the reserved band holds 1,000 manual ids across all events (EventInvariants.cs:65,68), and it is allocated from the maximum in use rather than from a free list, so soft-deleted manual rooms still consume their number. What happens at exhaustion is defined (Room.ManualIdRangeExhausted, AddRoomHandler.cs:141); whether any reclamation exists is not determinable from source.

QuestionDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.DTOs · MMCA.ADC.Conference.Application/Questions/DTOs/QuestionDTOMapper.cs:12 · Level 8 · class (sealed partial)

  • What it is: the outbound mapper that turns a Question aggregate into the QuestionDTO the API returns. Its single-entity method has no body: Mapperly generates it at compile time from the [Mapper] attribute (MMCA.ADC.Conference.Application/Questions/DTOs/QuestionDTOMapper.cs:11).
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Question, QuestionDTO and the QuestionIdentifierType alias (QuestionDTOMapper.cs:13); the Question entity and the QuestionDTO contract (QuestionDTOMapper.cs:1-2); Riok.Mapperly.Abstractions (NuGet, QuestionDTOMapper.cs:4).
  • Concept reinforced, source-generated DTO mapping: [Rubric §2, Design Patterns] assesses whether repetitive translation code is factored out rather than hand-written property by property: the partial declaration at QuestionDTOMapper.cs:16 is the whole contribution, and the generator emits the assignments into a companion generated file. [Rubric §12, Performance and Scalability] assesses the cost of that translation: because the body is generated, mapping is straight-line assignment with no reflection and no expression compilation on the read path. [Rubric §3, Clean Architecture] assesses direction: the domain type never leaves the Application layer, only the DTO does. The convention and its trade-offs are set out in ADR-001; contrast the inbound *RequestMapper classes such as QuestionCreateRequestMapper, which are hand-written because they must call a factory and are allowed to fail.
  • Walkthrough
    • [Mapper] (QuestionDTOMapper.cs:11) is the generator trigger; the class is sealed partial (QuestionDTOMapper.cs:12) so the generated half can be merged in.
    • public partial QuestionDTO MapToDTO(Question entity) (QuestionDTOMapper.cs:16) is the generated member. Mapping is by name and the pairs line up one for one: QuestionText, QuestionEntity, QuestionType, Sort, IsRequired and QuestionSource on the entity (MMCA.ADC.Conference.Domain/Questions/Question.cs:17, Question.cs:20, Question.cs:23, Question.cs:26, Question.cs:29, Question.cs:32) against the same names on the DTO (MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:35, QuestionDTO.cs:38, QuestionDTO.cs:41, QuestionDTO.cs:44, QuestionDTO.cs:47, QuestionDTO.cs:50), plus the Id and RowVersion members the DTO declares for IBaseDTO<QuestionIdentifierType> and IConcurrencyAware (QuestionDTO.cs:29, QuestionDTO.cs:32).
    • MapToDTOs (QuestionDTOMapper.cs:19-23) is hand-written: it null-guards the input (QuestionDTOMapper.cs:21) and projects with a collection expression over the generated single-item mapper, [.. entityCollection.Select(MapToDTO)] (QuestionDTOMapper.cs:22).
  • Why it's built this way: the collection method is worth a second look, because the interface already supplies exactly that body as a default interface implementation (MMCA.Common.Application/Interfaces/Mapping/IEntityDTOMapper.cs:27-32). A default interface member is reachable only through the interface, and two handlers inject this mapper as a concrete class (MMCA.ADC.Conference.Application/Questions/UseCases/Create/CreateQuestionHandler.cs:23, MMCA.ADC.Conference.Application/Questions/UseCases/Update/UpdateQuestionHandler.cs:21), so re-declaring the method on the class is what makes it callable from those call sites as well. The nullability direction is also deliberate: QuestionEntity, QuestionType and QuestionSource are non-nullable on the entity (Question.cs:20, Question.cs:23, Question.cs:32) and nullable on the DTO (QuestionDTO.cs:38, QuestionDTO.cs:41, QuestionDTO.cs:50), which is the usual shape for a read contract: the DTO tolerates more than the domain produces, so a contract change does not force a domain change.
  • Where it's used: injected as a concrete type by CreateQuestionHandler (CreateQuestionHandler.cs:23) and UpdateQuestionHandler (UpdateQuestionHandler.cs:21); resolved through its interface by EntityQueryService<TEntity, TEntityDTO, TIdentifierType>, registered for Question at MMCA.ADC.Conference.Application/DependencyInjection.cs:86, which drives every read on QuestionsController. The mapper itself is picked up by the module scan (DependencyInjection.cs:130). Covered by QuestionDTOMapperTests.
  • Caveats / not-in-source: the generated assignments are not readable in this file, only in build output, so a member added to the DTO without a matching entity member surfaces as a generator diagnostic at build time rather than as anything visible here. Note also that this mapper redacts nothing: unlike SpeakerDTOMapper, every question member is copied verbatim.

RemoveEventQuestionAnswerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerCommand.cs:9 · Level 8 · record (sealed)

  • What it is: the intent to remove one EventQuestionAnswer from an Event. Two positional parameters, the owning EventId and the EventQuestionAnswerId (MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerCommand.cs:9-11).
  • Depends on: ICacheInvalidating (RemoveEventQuestionAnswerCommand.cs:2, RemoveEventQuestionAnswerCommand.cs:11), the Event type for the cache prefix (RemoveEventQuestionAnswerCommand.cs:1, RemoveEventQuestionAnswerCommand.cs:14), and the EventIdentifierType / EventQuestionAnswerIdentifierType aliases (ADR-048).
  • Concept introduced, the remove-child command shape. Every child removal in this module is the same two-identifier record: the aggregate root first, the child second, nothing else. Naming the root is not redundant. The write has to travel through the aggregate so the invariant checks and the EventQuestionAnswerChanged domain event fire, so the handler loads the Event and calls a method on it rather than deleting a row by id. The removal itself is a soft delete: the framework's RemoveChildOrNotFound resolves the active child and calls its Delete() (MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:163, AuditableAggregateRootEntity.cs:171), which sets IsDeleted = true and fails with Error.AlreadyDeleted if the row was already retired (MMCA.Common.Domain/Entities/AuditableBaseEntity.cs:67-80). That is ADR-005 applied to a child entity. [Rubric §4, Domain-Driven Design] assesses whether children are mutated through their root: the command's shape makes that structurally unavoidable. [Rubric §8, Data Architecture] assesses deletion policy: rows are retired, not destroyed, and the EF global query filter hides them from every later read.
  • Walkthrough: a sealed record with EventId and EventQuestionAnswerId (RemoveEventQuestionAnswerCommand.cs:9-11), plus CachePrefix => $"{typeof(Event).FullName}:" (RemoveEventQuestionAnswerCommand.cs:14), keyed on the root because a cached event read carries its answers with it. CachingCommandDecorator<TCommand, TResult> acts on that prefix only after the inner handler succeeded, scopes it to the calling tenant, and evicts with CancellationToken.None so the cleanup outlives a caller that walked away (MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:61-74).
  • Why it's built this way: keeping the delete as an aggregate operation rather than a repository-level ExecuteDelete preserves domain events, audit stamping and soft-delete semantics, all three of which a bulk delete bypasses, as the repository contract warns in as many words (MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:419-423).
  • Where it's used: constructed by EventQuestionAnswersController.DeleteAsync as new RemoveEventQuestionAnswerCommand(eventId, id), with the event id taken from the query string and the answer id from the route (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:179-186, handler injected at EventQuestionAnswersController.cs:59). Handled by RemoveEventQuestionAnswerHandler, which adds the ownership rule the record does not express.
  • Caveats / not-in-source: unlike UnpublishEventCommand, this command carries no RowVersion, so a child removal is not a conditional write and a concurrent edit of the same answer is last-write-wins.

RemoveEventSpeakerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerCommand.cs:9 · Level 8 · record (sealed)

  • What it is: the intent to unlink a speaker from an event. It targets the EventSpeaker association row, not the speaker: the speaker aggregate is untouched.
  • Depends on: ICacheInvalidating (MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerCommand.cs:2, RemoveEventSpeakerCommand.cs:11), the Event type for the cache prefix (RemoveEventSpeakerCommand.cs:1, RemoveEventSpeakerCommand.cs:14), and the EventIdentifierType / EventSpeakerIdentifierType aliases.
  • Concept: nothing new; the remove-child shape taught by RemoveEventQuestionAnswerCommand. What it demonstrates is why link entities are worth having: removing a speaker from an event is a soft delete of one join row, so the speaker keeps existing, keeps its own identity, and can be linked to another edition of the conference. [Rubric §4, Domain-Driven Design] assesses aggregate boundaries: the association belongs to the event, the speaker is its own aggregate, and this command can only reach the former.
  • Walkthrough: public sealed record RemoveEventSpeakerCommand(EventIdentifierType EventId, EventSpeakerIdentifierType EventSpeakerId) : ICacheInvalidating (RemoveEventSpeakerCommand.cs:9-11) with CachePrefix => $"{typeof(Event).FullName}:" (RemoveEventSpeakerCommand.cs:14).
  • Where it's used: constructed by EventSpeakersController.DeleteAsync as new RemoveEventSpeakerCommand(eventId, id) (MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:186-193, handler injected at EventSpeakersController.cs:50); the same action then evicts three output-cache tags, conference:events, conference:speakers and conference (EventSpeakersController.cs:200). Handled by RemoveEventSpeakerHandler; its add-side counterpart is AddEventSpeakerCommand.

RemoveRoomCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom · MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomCommand.cs:9 · Level 8 · record (sealed)

  • What it is: the intent to remove a Room from an event: the owning EventId and the RoomId (MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomCommand.cs:9-11).
  • Depends on: ICacheInvalidating (RemoveRoomCommand.cs:2, RemoveRoomCommand.cs:11), the Event type for the cache prefix (RemoveRoomCommand.cs:1, RemoveRoomCommand.cs:14), and the EventIdentifierType / RoomIdentifierType aliases.
  • Concept: nothing new; the remove-child shape taught by RemoveEventQuestionAnswerCommand. Rooms are the child family whose identifiers can be externally assigned (Sessionize ids), which makes the soft delete matter more than usual: retiring the row rather than deleting it keeps a later refresh from colliding with a reused key, and the aggregate has an explicit restore path for a room that comes back (MMCA.ADC.Conference.Domain/Events/Event.cs:449-458). [Rubric §15, Best Practices & Code Quality] assesses uniformity: the third identical remove command in the same module is a sign the shape is a convention, so a reader who has understood one has understood all of them.
  • Walkthrough: public sealed record RemoveRoomCommand(EventIdentifierType EventId, RoomIdentifierType RoomId) : ICacheInvalidating (RemoveRoomCommand.cs:9-11) with CachePrefix => $"{typeof(Event).FullName}:" (RemoveRoomCommand.cs:14).
  • Where it's used: constructed by RoomsController.DeleteAsync as new RemoveRoomCommand(eventId, id) (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:260-267, handler injected at RoomsController.cs:96), which evicts the conference:rooms output-cache tag and returns 204 No Content (RoomsController.cs:272-273). Handled by RemoveRoomHandler; its add-side counterpart is AddRoomCommand.

UnpublishEventCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Unpublish · MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventCommand.cs:13 · Level 8 · record (sealed)

  • What it is: the mirror of PublishEventCommand: the intent to hide an event from attendees again, carrying the caller's concurrency token (MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventCommand.cs:13).
  • Depends on: ICacheInvalidating (UnpublishEventCommand.cs:2, UnpublishEventCommand.cs:13), the Event type for the cache prefix (UnpublishEventCommand.cs:1, UnpublishEventCommand.cs:16), the EventIdentifierType alias, and byte[] from the BCL.
  • Concept introduced, the conditional transition command. RowVersion is declared as a non-nullable byte[], not as an optional parameter with a default (UnpublishEventCommand.cs:13), and the XML comment states the reason: the token is read from the request's If-Match header and a transition that states no precondition never reaches this command (UnpublishEventCommand.cs:8-12). That is enforced at the boundary rather than here: SupportsIfMatchAttribute answers a missing header with 428 Precondition Required before the action body runs (MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:165) and rewrites a downstream conflict to 412 Precondition Failed (SupportsIfMatchAttribute.cs:130-153), which is why the parameter can be required here without a null check anywhere in the slice. [Rubric §6, CQRS and Event-Driven] assesses whether writes are modelled as named intents: "unpublish" is a type, not an IsPublished = false mutation, so the pipeline decorators, the idempotency store and the logs can tell the two directions apart without inspecting a payload field. [Rubric §8, Data Architecture] assesses concurrency control: the token turns the write into a database predicate (ADR-035).
  • Walkthrough: public sealed record UnpublishEventCommand(EventIdentifierType Id, byte[] RowVersion) : ICacheInvalidating (UnpublishEventCommand.cs:13) with CachePrefix => $"{typeof(Event).FullName}:" (UnpublishEventCommand.cs:16). Apart from the name, it is identical to PublishEventCommand (MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventCommand.cs:13-17).
  • Where it's used: constructed by EventsController.UnpublishAsync as new UnpublishEventCommand(id, rowVersion), where rowVersion comes from SupportsIfMatchAttribute.RequiredToken(HttpContext) (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:306-310, handler injected at EventsController.cs:51). The POST {id}/unpublish endpoint carries [Idempotent] and [SupportsIfMatch] and declares 409, 412 and 428 (EventsController.cs:295-300); on success it evicts the conference:events output-cache tag and returns 204 No Content (EventsController.cs:314-315). Handled by UnpublishEventHandler.

UpdateEventQuestionAnswerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerCommand.cs:10 · Level 8 · record (sealed)

  • What it is: the intent to change the text of an existing answer: the owning EventId, the EventQuestionAnswerId and the new AnswerValue (MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerCommand.cs:10-13).
  • Depends on: ICacheInvalidating (UpdateEventQuestionAnswerCommand.cs:2, UpdateEventQuestionAnswerCommand.cs:13), the Event type for the cache prefix (UpdateEventQuestionAnswerCommand.cs:1, UpdateEventQuestionAnswerCommand.cs:16), and the EventIdentifierType / EventQuestionAnswerIdentifierType aliases.
  • Concept: the remove-child shape plus one payload field. The detail worth noticing is what the record does not carry: no author, no timestamp. Ownership is decided server-side by UpdateEventQuestionAnswerHandler from ICurrentUserService, so a caller cannot claim to be editing on someone else's behalf by shaping the payload. [Rubric §11, Security] assesses whether identity is ambient rather than client-supplied: the absence of an owner field is the enforcement.
  • Walkthrough: public sealed record UpdateEventQuestionAnswerCommand(EventIdentifierType EventId, EventQuestionAnswerIdentifierType EventQuestionAnswerId, string AnswerValue) : ICacheInvalidating (UpdateEventQuestionAnswerCommand.cs:10-13) with CachePrefix => $"{typeof(Event).FullName}:" (UpdateEventQuestionAnswerCommand.cs:16). The payload rules are enforced twice on the way down: UpdateEventQuestionAnswerCommandValidator rejects an empty or over-long value at the boundary, and EventInvariants.EnsureAnswerValueIsValid runs again inside EventQuestionAnswer.UpdateAnswer (MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:73).
  • Where it's used: constructed by EventQuestionAnswersController.UpdateAsync as new UpdateEventQuestionAnswerCommand(request.EventId, id, request.AnswerValue), taking the event id from the UpdateEventQuestionAnswerRequest body and the answer id from the route (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:163-174, handler injected at EventQuestionAnswersController.cs:58). Handled by UpdateEventQuestionAnswerHandler.

UpdateRoomCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom · MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommand.cs:15 · Level 8 · record (sealed)

  • What it is: the full-replacement update for one room inside an event. It is the widest command in the event slice: two ids plus the six room fields (MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommand.cs:15-23).
  • Depends on: ICacheInvalidating (UpdateRoomCommand.cs:2, UpdateRoomCommand.cs:23); the Event type, referenced only for the cache prefix (UpdateRoomCommand.cs:1, UpdateRoomCommand.cs:26); the EventIdentifierType and RoomIdentifierType aliases.
  • Concept reinforced, the whole-object update command: [Rubric §9, API and Contract Design] assesses whether a mutation contract is unambiguous. Every optional field is declared nullable (Capacity, Floor, Location, AccessibilityInfo, UpdateRoomCommand.cs:20-23) and is passed through to the domain as sent (MMCA.ADC.Conference.Domain/Events/Event.cs:440), so omitting one clears it rather than leaving it alone. That is the defining property of a PUT-shaped command, and it is why RoomsController binds it from a complete UpdateRoomRequest body (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:236). [Rubric §21, Accessibility] is touched at the data level: AccessibilityInfo (UpdateRoomCommand.cs:23) carries a room's accessibility notes to attendees, so the write path preserves that information as a first-class member rather than folding it into free text.
  • Walkthrough: a sealed record with eight positional parameters (UpdateRoomCommand.cs:15-23), each documented individually (UpdateRoomCommand.cs:7-14), and the single CachePrefix member keyed on the Event type name (UpdateRoomCommand.cs:26). The prefix is the parent's, not the room's, because a room is a child of the event aggregate and every cached read that could contain it is keyed under Event. Note what is absent: no RowVersion, so unlike UnpublishEventCommand a room edit is last-write-wins.
  • Where it's used: constructed by RoomsController's PUT /Rooms/{id} action (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:234-249, handler injected at RoomsController.cs:95), which evicts the conference:rooms output-cache tag and returns 204 No Content on success (RoomsController.cs:254-255); validated by UpdateRoomCommandValidator; handled by UpdateRoomHandler.

UpdateEventQuestionAnswerCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerCommandValidator.cs:16 · Level 9 · class (sealed)

  • What it is: the FluentValidation validator for UpdateEventQuestionAnswerCommand. One chained rule on AnswerValue: present, and no longer than the ceiling any answer type can reach (MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerCommandValidator.cs:19-25).
  • Depends on: FluentValidation's AbstractValidator<T> (NuGet, UpdateEventQuestionAnswerCommandValidator.cs:1, UpdateEventQuestionAnswerCommandValidator.cs:16) and QuestionInvariants for the length constant (UpdateEventQuestionAnswerCommandValidator.cs:2, UpdateEventQuestionAnswerCommandValidator.cs:23).
  • Concept introduced, splitting a business rule by what the boundary can know. BR-124 says an answer must match its question's type: a Rating is an integer 1 to 5, an Email is a valid address, a Text answer is at most 2000 characters (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:110-129). A validator sees only the command, and the command does not carry the question's type, so only the type-independent half of the rule can live here. The class comment says exactly that and names where the rest lives (UpdateEventQuestionAnswerCommandValidator.cs:10-15). [Rubric §24, Forms, Validation and UX Safety] assesses whether bad input is rejected before business logic runs with a message a client can act on: both failures carry a human message and a stable dotted code, EventQuestionAnswer.AnswerValue.Required and EventQuestionAnswer.AnswerValue.TooLong (UpdateEventQuestionAnswerCommandValidator.cs:22, UpdateEventQuestionAnswerCommandValidator.cs:25). [Rubric §6, CQRS & Event-Driven Design] assesses whether that happens uniformly: the handler never calls this class. ValidatingCommandDecorator<TCommand, TResult> runs every registered validator for the command type before the transactional decorator opens a transaction (ADR-014), so a malformed answer costs no database work. [Rubric §4, Domain-Driven Design] assesses rule placement: the ceiling here is the same constant the domain uses, borrowed rather than restated.
  • Walkthrough: public sealed class UpdateEventQuestionAnswerCommandValidator : AbstractValidator<UpdateEventQuestionAnswerCommand> (UpdateEventQuestionAnswerCommandValidator.cs:16) with an expression-bodied constructor (UpdateEventQuestionAnswerCommandValidator.cs:18). RuleFor(x => x.AnswerValue) chains .NotEmpty() with the message "Answer value is required." (:19-22) and .MaximumLength(QuestionInvariants.TextAnswerMaxLength) (:23), whose message interpolates the same constant so the text cannot drift from the limit (:24). The constant is 2000 (MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:28), the identical value ValidateTextAnswer enforces when the question type is known (QuestionInvariants.cs:145-157).
  • Why it's built this way: the type-specific half of BR-124 needs the owning question, which is a database read, so it runs in the handler that has the entity in hand rather than in the validator (UpdateEventQuestionAnswerCommandValidator.cs:12-15). Keeping the boundary rule cheap is the point: the validator only reads the message.
  • Where it's used: discovered by the module's convention scan, services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:133), and executed by ValidatingCommandDecorator<TCommand, TResult> ahead of UpdateEventQuestionAnswerHandler.
  • Caveats / not-in-source: two asymmetries are visible in source and unexplained by it. First, the add-side counterpart it says it mirrors, AddEventQuestionAnswerCommandValidator, declares only the NotEmpty rule and no length ceiling (MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerCommandValidator.cs:10-14), so the 2000-character boundary check exists on the update path only. Second, the type-specific BR-124 check is called on the add path (MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:78) but not by UpdateEventQuestionAnswerHandler; on the update path the deepest remaining guard is EventInvariants.EnsureAnswerValueIsValid, whose ceiling is the wider AnswerValueMaxLength of 4000 (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:59, EventInvariants.cs:139-142). No test in the repository references this validator by name.

UpdateRoomCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom · MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:7 · Level 9 · class (sealed)

  • What it is: the FluentValidation validator for UpdateRoomCommand. It declares no rule of its own; its entire body composes six shared rule sets (MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomCommandValidator.cs:11-16).
  • Depends on: FluentValidation's AbstractValidator<T> (NuGet, UpdateRoomCommandValidator.cs:1, UpdateRoomCommandValidator.cs:7); RoomNameRules<T>, RoomSortRules<T>, RoomCapacityRules<T>, RoomFloorRules<T>, RoomLocationRules<T> and RoomAccessibilityInfoRules<T> from the module's Events.Validation folder (UpdateRoomCommandValidator.cs:2).
  • Concept reinforced, rule composition with Include: [Rubric §15, Best Practices & Code Quality] assesses whether one constraint is written once: rather than restate the room constraints in the add validator and again here, both Include the same generic rule sets, parameterized by a property selector. Include merges the included validator's rules in as though they had been declared inline, so composition costs nothing at validation time. [Rubric §24, Forms, Validation and UX Safety] covers what those rules produce: each carries a human message and a stable error code, for example Room.Name.Required and Room.Name.MaxLength (MMCA.ADC.Conference.Application/Events/Validation/RoomValidationRules.cs:18-19), so a client can branch on the code instead of parsing English. The ceilings come from the domain, not from the validator: EventInvariants.RoomNameMaxLength, RoomFloorMaxLength, RoomLocationMaxLength and RoomAccessibilityInfoMaxLength (MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:47, EventInvariants.cs:49, EventInvariants.cs:52, EventInvariants.cs:55) forward to the shared DTO constants 255, 100, 255 and 500 (MMCA.ADC.Conference.Shared/Rooms/RoomDTO.cs:16, RoomDTO.cs:19, RoomDTO.cs:22, RoomDTO.cs:25), which are the same numbers the domain invariant enforces (EventInvariants.cs:137), so the form limit, the API limit and the domain limit cannot drift apart.
  • Walkthrough: a sealed class whose whole body is a six-line constructor (UpdateRoomCommandValidator.cs:9-17). Four of the six rule sets contribute no rule bodies of their own, they bind a framework base to one field: RoomSortRules<T> subclasses NonNegativeIntRules<T> with the code Room.Sort.Negative (RoomValidationRules.cs:26-31), and RoomFloorRules<T>, RoomLocationRules<T> and RoomAccessibilityInfoRules<T> each subclass OptionalStringRules<T> with a display name, a ceiling and a code, so a null value passes and only an over-long one fails (RoomValidationRules.cs:51-56, RoomValidationRules.cs:63-68, RoomValidationRules.cs:75-80). The two that write their own chain are RoomNameRules<T>, required plus max length (RoomValidationRules.cs:13-19), and RoomCapacityRules<T>, which demands a capacity greater than zero but only when one was supplied, via .When(x => selector.Compile()(x) is not null) (RoomValidationRules.cs:37-44).
  • Why it's built this way: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every command that includes the rule set picks it up, which is why AddRoomCommandValidator includes the identical six. Running them ahead of the transaction is the pipeline's job: ValidatingCommandDecorator<TCommand, TResult> sits outside TransactionalCommandDecorator<TCommand, TResult> (ADR-014), so a malformed room never opens a database transaction.
  • Where it's used: discovered by the module's validator scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133) and run by the validating decorator ahead of UpdateRoomHandler. Covered by UpdateRoomCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/Validation/CommandValidatorTests.cs:116-118).
  • Caveats / not-in-source: name uniqueness within an event is not validated here. It cannot be: the rule needs the event's other rooms, so it lives in the aggregate as EnsureRoomNameIsUnique and comes back as the invariant error Event.Room.Duplicate (MMCA.ADC.Conference.Domain/Events/Event.cs:695-712, code at Event.cs:680).

RemoveEventQuestionAnswerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerHandler.cs:21 · Level 10 · class (sealed partial)

  • What it is: the handler for RemoveEventQuestionAnswerCommand. It is the framework's load-mutate-save template plus one authorization rule: an attendee may delete only their own answer, an Organizer may delete any (BR-52/BR-53, MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerHandler.cs:11-14).
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> closed over the command, Event and EventIdentifierType (RemoveEventQuestionAnswerHandler.cs:25); IUnitOfWork (RemoveEventQuestionAnswerHandler.cs:22); ICurrentUserService (:22); ILogger<RemoveEventQuestionAnswerHandler> (:23); RoleNames from MMCA.Common.Shared.Auth (:6, :43); the EventQuestionAnswer child type; and Result / Error.
  • Concept introduced, the aggregate-write template method. No handler in this unit writes a HandleAsync body. The shared workflow lives once in MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> and runs six fixed steps: resolve the repository from the unit of work, load the aggregate through the overridable LoadAsync, fail with Error.NotFound sourced to the handler's own type name when it is gone, stamp the caller's RowVersion as the tracked entity's original value when the command reports one, run MutateAsync, and save plus log only when the mutation succeeded (MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:280-308). The thin subclass a verb-style command uses adds only the ICommandHandler<TCommand, Result> implementation, flattening the workflow's Result<TEntity> to a bare Result (MutateEntityHandlerBase.cs:326-331). A concrete handler therefore supplies overrides, not control flow: EntityId is the one abstract member (MutateEntityHandlerBase.cs:80), Includes defaults to empty (:69), AsTracking defaults to true because a no-tracking load would make the mutation a silent no-op (:75), RowVersion defaults to null for an unconditional endpoint (:90), and LogMutated is a deliberate no-op the module fills with its own vocabulary (:186-189). [Rubric §2, Design Patterns] assesses whether recurring structure is captured once: this is the template method pattern, with the variable steps as protected overrides. [Rubric §15, Best Practices & Code Quality] assesses the payoff: the four handlers below are 37 to 63 lines each because the load, the not-found error, the concurrency stamp and the save-on-success discipline are inherited rather than retyped, and cannot be got subtly wrong per slice.
  • Concept introduced, row-level ownership enforced in the handler. Role-based authorization at the endpoint answers "may this kind of user delete answers"; it cannot answer "may this user delete this answer". That second question needs the row, so it is asked inside MutateAsync, after the aggregate is loaded and before the domain method is called. The predicate is a three-way test (RemoveEventQuestionAnswerHandler.cs:43-44): the answer exists and is not already deleted, the caller is not in the Organizer role, and answer.CreatedBy != currentUserService.UserId!.Value. CreatedBy is not a field the client sends: it is stamped automatically by the audit pipeline when the row was written, and the current user is read from the ambient claims principal, so both sides of the comparison are server-owned. A failure returns Error.Forbidden with the code EventQuestionAnswer.NotOwner (:45-49), a distinct outcome from not-found that maps to 403 rather than 404. [Rubric §11, Security] assesses whether authorization is enforced at the resource and not only at the route: this is object-level authorization, the check a role attribute structurally cannot perform. [Rubric §4, Domain-Driven Design] assesses rule placement: ownership is an application policy about the caller, so it lives here, while "does this child exist" stays in the domain.
  • Walkthrough
    • Primary constructor and base call (RemoveEventQuestionAnswerHandler.cs:21-25): three dependencies, and the unit of work is forwarded to the base rather than used directly.
    • Includes => [nameof(Event.EventQuestionAnswers)] (:27) is load-bearing twice: the ownership scan reads the child collection, and the aggregate's remove method searches the same list.
    • EntityId(command) => command.EventId (:30) tells the workflow to load the root, not the child.
    • MutateAsync (:33-53) null-guards both arguments (:38-39), finds the candidate in memory with FirstOrDefault(a => a.Id == command.EventQuestionAnswerId && !a.IsDeleted) (:42), applies the ownership gate (:43-50), and otherwise returns entity.RemoveEventQuestionAnswer(command.EventQuestionAnswerId) wrapped in Task.FromResult (:52). The aggregate resolves the child through the shared RemoveChildOrNotFound helper, soft-deletes it and raises EventQuestionAnswerChanged with DomainEntityState.Deleted (MMCA.ADC.Conference.Domain/Events/Event.cs:666-677).
    • LogMutated (:56-57) is called by the workflow only after the save committed, and forwards to the source-generated LogQuestionAnswerRemovedFromEvent (:59-60), which emits the answer id and the event id as structured fields. [Rubric §13, Observability and Operability] assesses diagnostics: the [LoggerMessage] partial is allocation-free and its fields are queryable.
  • Why it's built this way: putting the gate inside MutateAsync rather than before the load is what makes it correct. The check needs the persisted CreatedBy, and a refusal there short-circuits the workflow before SaveChangesAsync is ever reached (MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:294-296), so a forbidden delete opens no write.
  • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133) and dispatched through the decorator pipeline by EventQuestionAnswersController.DeleteAsync (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:59, EventQuestionAnswersController.cs:179-190), which returns 204 No Content on success. Covered by RemoveEventQuestionAnswerHandlerTests.
  • Caveats / not-in-source: this remove derives from the general MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> while its two siblings below use RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType>; nothing in source states why. The two bases differ only in that the child base makes Includes a required override (MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:152), and this handler overrides it anyway. Also, currentUserService.UserId!.Value (:43) assumes an authenticated caller, and the ownership gate is skipped entirely when no matching active answer is found (:43), leaving the not-found decision to the aggregate.

UnpublishEventHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Unpublish · MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventHandler.cs:13 · Level 10 · class (sealed partial)

  • What it is: the handler for UnpublishEventCommand, the exact mirror of PublishEventHandler with entity.Unpublish() in place of entity.Publish().
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> closed over the command, Event and EventIdentifierType (MMCA.ADC.Conference.Application/Events/UseCases/Unpublish/UnpublishEventHandler.cs:16); IUnitOfWork (:14); ILogger<UnpublishEventHandler> (:15); Result.
  • Concept introduced, stamping a client token as the tracked entity's original value. This is the application-side half of ADR-035, and in this handler it is one line: protected override byte[]? RowVersion(UnpublishEventCommand command) => command.RowVersion (UnpublishEventHandler.cs:25), with the reason stated inline just above (:21-22). The workflow then calls repository.SetOriginalRowVersion(entity, rowVersion) when the reported token is non-empty (MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:291-292), and the repository writes that value into EF's change tracker as the RowVersion property's original value (MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:75-83), so the UPDATE EF emits carries WHERE RowVersion = @clientToken. If someone else touched the row, zero rows match, EF raises DbUpdateConcurrencyException, and because the endpoint is decorated with SupportsIfMatchAttribute the resulting conflict is rewritten to 412 Precondition Failed (MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:130-153). [Rubric §8, Data Architecture] assesses concurrency control: the arbitration is a database predicate, not an application-level compare. [Rubric §12, Performance and Scalability] assesses contention: optimistic concurrency takes no locks, so simultaneous readers are never blocked and only the losing writer pays. [Rubric §6, CQRS and Event-Driven] assesses intent modelling: the reverse transition is its own command and its own handler, so both directions are separately auditable and separately loggable.
  • Walkthrough: primary constructor and base call (UnpublishEventHandler.cs:13-16); EntityId(command) => command.Id (:19); the RowVersion override (:25); MutateAsync returning Task.FromResult(entity.Unpublish()) (:28-32); LogMutated forwarding to the generated LogEventUnpublished (:35-36, declared :38-39). No Includes override, so the base issues a by-id load with no eager-loaded navigations (MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:70, :158): the transition touches only the root's own flag, so loading the children would be wasted I/O. The decision itself is the aggregate's: Event.Unpublish fails with the invariant Event.AlreadyUnpublished when the flag is already clear, otherwise sets IsPublished = false and raises EventChanged with DomainEntityState.Updated (MMCA.ADC.Conference.Domain/Events/Event.cs:319-335).
  • Why it's built this way: unpublishing is the direction where the stale-view guard earns its keep. Hiding an event a colleague has just re-published, based on a page rendered minutes ago, is precisely the mistake ADR-035 converts into a 412 rather than a silent overwrite. Returning the aggregate's Result unchanged preserves the Event.AlreadyUnpublished code all the way to the HTTP response instead of flattening it into a generic 400.
  • Where it's used: dispatched by EventsController.UnpublishAsync (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:52, EventsController.cs:301-316), which evicts the conference:events output-cache tag and returns 204 No Content on success (EventsController.cs:314-315). Covered by UnpublishEventHandlerTests.
  • Caveats / not-in-source: unpublishing changes what non-privileged readers can see, but nothing in this handler evicts a cached read directly. Distributed-cache eviction is the caching decorator's job, driven by the CachePrefix on UnpublishEventCommand (MMCA.Common.Application/UseCases/Decorators/CachingCommandDecorator.cs:61-74), and the separate ASP.NET output cache is evicted by the controller.

UpdateEventQuestionAnswerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateEventQuestionAnswer · MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerHandler.cs:21 · Level 10 · class (sealed partial)

  • What it is: the handler for UpdateEventQuestionAnswerCommand. It applies the same BR-52/BR-53 ownership gate as its remove twin and then asks the aggregate to change the answer text (MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerHandler.cs:11-14).
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> closed over the command, Event and EventIdentifierType (UpdateEventQuestionAnswerHandler.cs:25); IUnitOfWork (:21); ICurrentUserService (:22); ILogger<UpdateEventQuestionAnswerHandler> (:23); RoleNames (:6, :43); the EventQuestionAnswer child type; Result / Error.
  • Concept: nothing new; the template method taught by RemoveEventQuestionAnswerHandler and the row-level ownership check taught there too, reproduced line for line with "You can only update your own answers." in place of the delete message (:45-49). Reading the two side by side is the fastest way to see that the rule is a policy about the caller and the row, not about the verb. [Rubric §11, Security] assesses object-level authorization: identity comes from the ambient principal and the owner comes from the persisted audit field, never from the command. [Rubric §4, Domain-Driven Design] assesses invariant placement: the text rule stays on the entity, so no future caller can bypass it by writing a new handler.
  • Walkthrough: primary constructor and base call (UpdateEventQuestionAnswerHandler.cs:21-25); Includes => [nameof(Event.EventQuestionAnswers)] (:27); EntityId(command) => command.EventId (:30); MutateAsync (:33-55) null-guards (:38-39), finds the candidate answer (:42), applies the ownership gate returning Error.Forbidden with the code EventQuestionAnswer.NotOwner (:43-50), then returns entity.UpdateEventQuestionAnswer(command.EventQuestionAnswerId, command.AnswerValue) (:52-54). The aggregate re-resolves the child through GetEventQuestionAnswerOrNotFound (MMCA.ADC.Conference.Domain/Events/Event.cs:647, helper at Event.cs:692-695), delegates to answer.UpdateAnswer(...) which runs EventInvariants.EnsureAnswerValueIsValid before assigning (MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:71-80), and raises EventQuestionAnswerChanged with DomainEntityState.Updated only after both pass (Event.cs:629). LogMutated (:58-59) fires only after the commit, through the generated LogEventQuestionAnswerUpdated (:61-62).
  • Why it's built this way: the ownership predicate is duplicated between this handler and the remove handler rather than hoisted into a shared helper. With one condition and one message each, the two slices stay independently readable and independently changeable, which is the trade the vertical-slice layout makes on purpose ([Rubric §5, Vertical Slice]).
  • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); dispatched by EventQuestionAnswersController.UpdateAsync on PUT {id} (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:58, EventQuestionAnswersController.cs:163-174), which returns 204 No Content on success. Covered by UpdateEventQuestionAnswerHandlerTests.
  • Caveats / not-in-source: the type-specific half of BR-124 is not applied on this path. QuestionInvariants.EnsureAnswerValueMatchesQuestionType is called by the add-side handler (MMCA.ADC.Conference.Application/Events/UseCases/AddEventQuestionAnswer/AddEventQuestionAnswerHandler.cs:78) but nowhere in this file, so an update is checked only against the boundary rule in UpdateEventQuestionAnswerCommandValidator and the type-agnostic domain guard. Nothing in source states whether that is deliberate. As in the remove twin, currentUserService.UserId!.Value (:43) assumes an authenticated caller, and the log line records only the answer id (:61-62), so the audit trail for "who changed this text" lives in the row's LastModifiedBy stamp rather than in the log.

UpdateRoomHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.UpdateRoom · MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomHandler.cs:13 · Level 10 · class (sealed partial)

  • What it is: the handler for UpdateRoomCommand. The same template method as the handlers above, with all six room fields forwarded to the aggregate.
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> closed over the command, Event and EventIdentifierType (MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomHandler.cs:16); IUnitOfWork (:14); ILogger<UpdateRoomHandler> (:15); the Room child type; Result. No DTO mapper is injected, because the update returns no body: the base flattens the workflow to a bare Result (MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:327-332).
  • Concept reinforced, the handler as a pass-through to the root: [Rubric §4, Domain-Driven Design] assesses where the rules live, and this handler is the clearest example in the unit of them living elsewhere. It re-checks nothing UpdateRoomCommandValidator already checked and decides nothing the aggregate decides. Event.UpdateRoom (MMCA.ADC.Conference.Domain/Events/Event.cs:422-447) resolves the room or returns not-found (Event.cs:404-406), enforces name uniqueness excluding the room being edited (Event.cs:409-411), forwards to room.Update(...) for the field-level invariants (Event.cs:413-415), and raises RoomChanged with DomainEntityState.Updated only after all three pass (Event.cs:417). [Rubric §3, Clean Architecture] assesses dependency direction: the handler touches abstractions only, with no EF type in sight.
  • Walkthrough: primary constructor and base call (UpdateRoomHandler.cs:13-16); Includes => [nameof(Event.Rooms)] (:19), which is what makes the aggregate's uniqueness scan see the sibling rooms; EntityId(command) => command.EventId (:22); MutateAsync forwarding the six fields positionally to entity.UpdateRoom(...) inside Task.FromResult (:25-36); LogMutated calling the generated LogRoomUpdated with both ids (:39-40, declared :42-43).
  • Why it's built this way: the uniqueness rule is the reason the whole Rooms collection is loaded for what looks like a single-row edit. It is an aggregate-scoped invariant, so it can only be answered with the aggregate in hand; pushing it to a database index alone would surface as an opaque constraint violation instead of the typed Event.Room.Duplicate error (MMCA.ADC.Conference.Domain/Events/Event.cs:707).
  • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); invoked through the decorator pipeline by RoomsController's PUT /Rooms/{id} action (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:95, RoomsController.cs:234-256), which returns 204 No Content and evicts the conference:rooms output cache on success. Covered by UpdateRoomHandlerTests.
  • Caveats / not-in-source: no RowVersion override, so the base skips the concurrency stamp (MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:91, :290-291) and two organizers editing the same room concurrently are resolved last-write-wins, unlike the publish and unpublish transitions.

RemoveEventSpeakerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveEventSpeaker · MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerHandler.cs:13 · Level 11 · class (sealed partial)

  • What it is: the handler for RemoveEventSpeakerCommand. It is the remove-child template with nothing added: load the aggregate with its speakers, delegate, save, log.
  • Depends on: RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType> closed over the command, Event and EventIdentifierType (MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventSpeaker/RemoveEventSpeakerHandler.cs:16); IUnitOfWork (:14); ILogger<RemoveEventSpeakerHandler> (:15); Result.
  • Concept introduced, the child-remove base that makes one override mandatory. RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType> adds no workflow of its own: it derives from MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> and re-declares Includes as abstract override (MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:143-153). That single change converts a bug class into a compile error: a remove whose parent was loaded without the child collection scans an empty list and reports a wrong not-found, so the base refuses to let a subclass forget the include (ChildEntityHandlerBase.cs:128-133). [Rubric §1, SOLID] assesses the Liskov relationship between the two bases: the child base narrows the contract by removing a default, and adds no behavior. [Rubric §15, Best Practices and Code Quality] assesses whether correctness is pushed to the compiler rather than to review.
  • Walkthrough: primary constructor and base call (RemoveEventSpeakerHandler.cs:13-16); the required Includes => [nameof(Event.EventSpeakers)] (:19); EntityId(command) => command.EventId (:22); MutateAsync returning Task.FromResult(entity.RemoveEventSpeaker(command.EventSpeakerId)) (:25-29); LogMutated calling the generated LogSpeakerRemovedFromEvent with the speaker and event ids (:32-33, declared :35-36). The aggregate resolves the child through the framework's RemoveChildOrNotFound, which finds the active child, calls its Delete() and propagates that failure if the row was already retired (MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:156-178), then raises EventSpeakerChanged with DomainEntityState.Deleted (MMCA.ADC.Conference.Domain/Events/Event.cs:592-603).
  • Why it's built this way: there is no ownership rule here because an event-speaker link has no per-user owner: the endpoint's role gate is the whole authorization story. The handler also adds no error text of its own, so the aggregate stays the single author of "why not", which is what makes the remove handlers in this module readable as one pattern with local variations rather than as independent implementations.
  • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); dispatched by EventSpeakersController.DeleteAsync (MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:50, EventSpeakersController.cs:186-200). Covered by RemoveEventSpeakerHandlerTests.

RemoveRoomHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.RemoveRoom · MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomHandler.cs:13 · Level 11 · class (sealed partial)

  • What it is: the handler for RemoveRoomCommand. Structurally identical to RemoveEventSpeakerHandler, differing only in the include name, the aggregate method and the log message.
  • Depends on: RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType> closed over the command, Event and EventIdentifierType (MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomHandler.cs:16); IUnitOfWork (:14); ILogger<RemoveRoomHandler> (:15); the Room child type; Result.
  • Concept: nothing new; the remove-child template taught by RemoveEventSpeakerHandler. The thing worth carrying away is what a soft delete means for a room specifically: sessions scheduled into it keep referencing a row that still exists, so history stays readable rather than turning into dangling identifiers (ADR-005). [Rubric §8, Data Architecture] assesses referential integrity under deletion: retiring the row keeps every prior reference resolvable.
  • Walkthrough: primary constructor and base call (RemoveRoomHandler.cs:13-16); the required Includes => [nameof(Event.Rooms)] (:19); EntityId(command) => command.EventId (:22); MutateAsync returning Task.FromResult(entity.RemoveRoom(command.RoomId)) (:25-29); LogMutated calling the generated LogRoomRemoved with both ids (:32-33, declared :35-36). Event.RemoveRoom (MMCA.ADC.Conference.Domain/Events/Event.cs:511-521) delegates to RemoveChildOrNotFound (Event.cs:486) and raises RoomChanged with DomainEntityState.Deleted (Event.cs:491), which the outbox picks up in the same transaction as the soft delete (ADR-003).
  • Where it's used: registered by the module scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); dispatched by RoomsController.DeleteAsync, which takes the room id from the route and the event id from the query string (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:96, RoomsController.cs:260-273) and evicts the conference:rooms output cache before returning 204 No Content. Covered by RemoveRoomHandlerTests.
  • Caveats / not-in-source: the room name-uniqueness rule excludes soft-deleted rooms from its scan (MMCA.ADC.Conference.Domain/Events/Event.cs:700-703), so a name freed by this handler becomes reusable immediately, and a room that comes back through a Sessionize refresh goes through the aggregate's separate restore path rather than a new insert (Event.cs:422-431).

AddSpeakerCategoryItemCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.AddSpeakerCategoryItem · MMCA.ADC.Conference.Application/Speakers/UseCases/AddSpeakerCategoryItem/AddSpeakerCategoryItemCommand.cs:13 · Level 8 · record

  • What it is: the command that tags a speaker with a category item. Category items are how a speaker's topics and locality are modeled, so this is the "tag this speaker" message. Three positional parameters: the owning SpeakerId, an optional SpeakerCategoryItemId for the join entity, and the CategoryItemId being associated (AddSpeakerCategoryItemCommand.cs:13-16).
  • Depends on: ICacheInvalidating, the pipeline marker it implements (AddSpeakerCategoryItemCommand.cs:16); the Speaker domain type, referenced only to build the cache prefix; and the SpeakerIdentifierType (a System.Guid), SpeakerCategoryItemIdentifierType (an int), and CategoryItemIdentifierType (an int) module aliases (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19, MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18, MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6, ADR-048).
  • Concept introduced, the nullable child id on an Add command: the second parameter is SpeakerCategoryItemIdentifierType?, documented as "Explicit ID for the new join entity, or null for database-generated identity" (AddSpeakerCategoryItemCommand.cs:11). The REST path always passes null and lets the database assign the key (SpeakerCategoryItemsController at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:170); the parameter exists so a caller that already knows the id can supply it, which is exactly the shape the aggregate's method takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:314-316). [Rubric §9, API and Contract Design] assesses whether a contract states precisely what a caller may decide: making the id nullable rather than defaulted keeps "let the database choose" distinct from "I chose zero".
  • Walkthrough: the record body holds one member, CachePrefix => $"{typeof(Speaker).FullName}:" (AddSpeakerCategoryItemCommand.cs:18-19). That satisfies ICacheInvalidating, so CachingCommandDecorator<TCommand, TResult> evicts every cache entry under the Speaker prefix after the command succeeds. The join row has no cache namespace of its own, which is the point: tagging a speaker flushes the whole speaker read surface in one stroke rather than requiring per-query bookkeeping, so a stale nested category item cannot survive inside an already-cached speaker.
  • Why it's built this way: caching and invalidation are declared by the message and applied uniformly by the pipeline (ADR-026, ADR-014), never hand-wired inside a handler. [Rubric §12, Performance & Scalability]: the command says what it invalidates; it does not know how. Note what is absent: no ITransactional, because the whole write lands in one aggregate and one SaveChangesAsync with no cross-context event to keep atomic.
  • Where it's used: validated by AddSpeakerCategoryItemCommandValidator, handled by AddSpeakerCategoryItemHandler, and constructed by the POST /SpeakerCategoryItems action from an AddSpeakerCategoryItemRequest body (SpeakerCategoryItemsController.cs:165-171, handler injected at SpeakerCategoryItemsController.cs:50), on a controller gated by the SpeakersManage permission at the class level (SpeakerCategoryItemsController.cs:47, ADR-020). That action also carries [Idempotent] (SpeakerCategoryItemsController.cs:164), so a retried request with the same Idempotency-Key replays the first response instead of adding a second row, and the comment above it records why the attribute is written out here rather than inherited (SpeakerCategoryItemsController.cs:157-162). Its mirror image is RemoveSpeakerCategoryItemCommand.

LinkUserToSpeakerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerCommand.cs:13 · Level 8 · record

  • What it is: the write message an organizer sends to attach an application account to a speaker profile (BR-209). Two ids and nothing else: SpeakerId and UserId (LinkUserToSpeakerCommand.cs:13).
  • Depends on: ICacheInvalidating and ITransactional, both markers implemented at LinkUserToSpeakerCommand.cs:13; Speaker, referenced only to build the cache prefix; and the module identifier aliases SpeakerIdentifierType (a System.Guid, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19) and UserIdentifierType (owned by Identity).
  • Concept introduced, the command that declares its own transaction: [Rubric §6, CQRS and Event-Driven] assesses whether a write is modeled as an explicit single-purpose message: this record carries intent only, and the two marker interfaces tell the pipeline how to run it. [Rubric §12, Performance & Scalability] assesses whether such concerns are declared rather than hand-coded. Implementing ITransactional opts the message into TransactionalCommandDecorator<TCommand, TResult>, which the framework registers first and therefore wraps innermost in the chain (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:131-137), and the XML comment states the reason plainly (LinkUserToSpeakerCommand.cs:7-8): the Speaker link and the outbox row that carries the cross-context User update must commit together or not at all. Implementing ICacheInvalidating with CachePrefix => $"{typeof(Speaker).FullName}:" (LinkUserToSpeakerCommand.cs:16) is what makes CachingCommandDecorator<TCommand, TResult> drop every cached read keyed under the Speaker type after a successful link.
  • Walkthrough: a sealed record with a two-parameter positional constructor (LinkUserToSpeakerCommand.cs:13) and one member, the expression-bodied CachePrefix (LinkUserToSpeakerCommand.cs:15-16). The cross-module identifier pairing is the notable part: UserIdentifierType is Identity's alias, carried here as a plain scalar because the two modules own separate databases and there is no foreign key to point at (ADR-006, ADR-048).
  • Why it's built this way: the two markers move durability and cache eviction out of the handler and into the pipeline, so LinkUserToSpeakerHandler reads as pure domain orchestration (ADR-014). Records give value equality and immutability for free.
  • Where it's used: constructed by the PUT /Speakers/{id}/link action from a LinkUserRequest body (SpeakersController at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:383, handler injected at SpeakersController.cs:52, permission-gated at SpeakersController.cs:376); handled by LinkUserToSpeakerHandler. Its inverse is UnlinkUserFromSpeakerCommand.

QuestionCreateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Create · MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequest.cs:10 · Level 8 · record

  • What it is: the create-request DTO for a conference question (the feedback and survey questions attached to sessions, events, and speakers). It doubles as the command: CreateQuestionHandler is registered as ICommandHandler<QuestionCreateRequest, Result<QuestionDTO>> against this type, so there is no separate CreateQuestionCommand.
  • Depends on: ICreateRequest, an empty marker used as a generic constraint by IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> and by CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>; ICacheInvalidating; the Question type for the cache prefix; and the QuestionIdentifierType alias (QuestionCreateRequest.cs:10, QuestionCreateRequest.cs:13).
  • Concept introduced, the request whose id field is accepted and then thrown away: [Rubric §9, API and Contract Design] assesses whether a contract is honest about what the caller controls. Id is a settable init member (QuestionCreateRequest.cs:16), but its own doc comment says "Auto-generated by the handler; caller-provided values are ignored". That is not laziness: the question id space is shared with Sessionize, so CreateQuestionHandler allocates from a reserved manual range and overwrites whatever arrived (CreateQuestionHandler.cs:86). Compare SpeakerCreateRequest, where the caller-supplied id IS honored because a speaker id is a Sessionize-assigned GUID. Two create requests in the same module, opposite id policies, and the only place either is stated is a one-line comment on the property.
  • Walkthrough: a record class (not sealed) with init-only members. CachePrefix => $"{typeof(Question).FullName}:" (QuestionCreateRequest.cs:13) is the invalidation tag. Exactly one member is required, QuestionText (QuestionCreateRequest.cs:19), so the record cannot be constructed without it. The rest are optional: QuestionEntity (QuestionCreateRequest.cs:22), QuestionType (QuestionCreateRequest.cs:25), Sort (QuestionCreateRequest.cs:28), and IsRequired (QuestionCreateRequest.cs:31).
  • Why it's built this way: required plus init gives compile-time enforcement of the minimum payload while leaving the rest optional, and collapsing request and command into one type keeps a simple create slice to a single message. [Rubric §5, Vertical Slice]: the request, its mapper, its validator, and its handler all sit in Questions/UseCases/Create.
  • Where it's used: bound from the body by QuestionsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Questions/QuestionsController.cs:95-98), and it is also the fourth generic argument of that controller's base AggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest> (QuestionsController.cs:42-43); validated by QuestionCreateRequestValidator; translated to a domain entity by QuestionCreateRequestMapper; handled by CreateQuestionHandler.
  • Caveats / not-in-source: QuestionEntity and QuestionType are declared nullable here, but Question.Create takes them as non-nullable and validates both against closed value lists, ["Session", "Event", "Speaker"] and ["Rating", "Text", "Email"] (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:31, QuestionInvariants.cs:34). QuestionCreateRequestMapper bridges the gap with ! (QuestionCreateRequestMapper.cs:22-23), so omitting either field is not a binding error but an invariant failure at create time. Nothing on this record says so.

SpeakerCategoryItemDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.DTOs · MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerCategoryItemDTOMapper.cs:12 · Level 8 · class (sealed partial)

  • What it is: the entity-to-DTO mapper for the SpeakerCategoryItem join entity. It is a Mapperly source-generated mapper: the class declares the signature, the generator writes the body at compile time.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SpeakerCategoryItem / SpeakerCategoryItemDTO / SpeakerCategoryItemIdentifierType (SpeakerCategoryItemDTOMapper.cs:13), and the Riok.Mapperly.Abstractions package for the [Mapper] attribute (SpeakerCategoryItemDTOMapper.cs:4, SpeakerCategoryItemDTOMapper.cs:11).
  • Concept introduced, compile-time mapping instead of runtime reflection: [Rubric §12, Performance and Scalability] assesses whether hot per-row work avoids reflection, and [Rubric §15, Best Practices and Code Quality] assesses whether generated code is preferred to hand-maintained boilerplate that can silently drift. [Mapper] on a partial class makes Mapperly emit the body of public partial SpeakerCategoryItemDTO MapToDTO(SpeakerCategoryItem entity) (SpeakerCategoryItemDTOMapper.cs:16) as plain property assignments in a generated file. There is no runtime configuration step and no reflection at map time; a property that cannot be matched is a build diagnostic, not a null at runtime. This is the "manual mapping over reflective auto-mapping" position of ADR-001, taken one step further: the compiler writes the manual mapping.
  • Walkthrough: two members.
    • MapToDTO (SpeakerCategoryItemDTOMapper.cs:16) is partial with no body; the generator supplies it.
    • MapToDTOs (SpeakerCategoryItemDTOMapper.cs:19-23) is written by hand: a null guard (SpeakerCategoryItemDTOMapper.cs:21), then a collection-expression spread over Select(MapToDTO) (SpeakerCategoryItemDTOMapper.cs:22). Re-declaring it on the class is what makes it callable through the concrete type, which matters because AddSpeakerCategoryItemHandler injects the concrete mapper rather than the interface (AddSpeakerCategoryItemHandler.cs:17).
  • Why it's built this way: DTO shaping stays a compile-checked, allocation-lean step owned by the Application layer, so the API contract cannot drift from the entity without a build error (ADR-001).
  • Where it's used: injected into AddSpeakerCategoryItemHandler (AddSpeakerCategoryItemHandler.cs:17), composed into SpeakerDTOMapper as a [UseMapper] field for the parent's child collection (SpeakerDTOMapper.cs:23-24), and resolved by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:119). The mapper itself is registered by the convention scan (DependencyInjection.cs:133), not by an explicit line. Covered by SpeakerCategoryItemDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerCategoryItemDTOMapperTests.cs).
  • Caveats / not-in-source: what the generated MapToDTO actually copies is decided by the property names on the entity and the DTO; the generated file is not in the repository, so the field list is only observable through the two type definitions and a build.

SpeakerCreateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequest.cs:11 · Level 8 · record

  • What it is: the create-request DTO for a conference speaker. Like QuestionCreateRequest it doubles as the command: CreateSpeakerHandler is registered as ICommandHandler<SpeakerCreateRequest, Result<SpeakerDTO>> against this type, so there is no separate CreateSpeakerCommand.
  • Depends on: three interfaces, all declared on one line (SpeakerCreateRequest.cs:11). ICreateRequest is the empty marker the generic request-mapper and create-handler contracts constrain on; ICacheInvalidating declares the eviction; and ISpeakerFieldsRequest is the module's own shape interface that lets one rule set validate both the create and the update request. It also references Speaker for the cache prefix and the SpeakerIdentifierType alias (SpeakerCreateRequest.cs:14, SpeakerCreateRequest.cs:17).
  • Concept introduced, the request that implements a validation shape: [Rubric §15, Best Practices & Code Quality] assesses whether a rule is written once. ISpeakerFieldsRequest names the six members the create and update paths validate identically (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/ISpeakerFieldsRequest.cs:12-31), so SpeakerFieldRules<T> can be declared once over that constraint instead of twice over two concrete records. The interface's own remark records the one deliberate omission: FullName stays off it, because only the create path carries the member and no validator has a rule for it (ISpeakerFieldsRequest.cs:9-10). [Rubric §9, API and Contract Design] also applies: the controller binds this record straight from the request body (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:311) and it is the fourth generic argument of the base controller (SpeakersController.cs:62), so the wire contract is an explicit versionable type rather than the domain entity leaking outward. [Rubric §5, Vertical Slice] applies to the folder: request, mapper, validator, and handler for Create all sit in Speakers/UseCases/Create.
  • Walkthrough: a record class (not sealed) with init-only members. CachePrefix (SpeakerCreateRequest.cs:13-14) is the invalidation tag. Id (SpeakerCreateRequest.cs:16-17) is a SpeakerIdentifierType, and its comment records that it is Sessionize-assigned: the caller supplies the key rather than the database generating it, which is what lets an import be idempotent. Three members are required, so the record cannot be constructed without them: FirstName (SpeakerCreateRequest.cs:20), LastName (SpeakerCreateRequest.cs:23), and FullName (SpeakerCreateRequest.cs:26). The rest are optional init members: Email (SpeakerCreateRequest.cs:29), Bio (SpeakerCreateRequest.cs:32), TagLine (SpeakerCreateRequest.cs:35), ProfilePicture (SpeakerCreateRequest.cs:38), the IsTopSpeaker flag (SpeakerCreateRequest.cs:41), and the four profile links TwitterHandle (SpeakerCreateRequest.cs:44), LinkedInUrl (SpeakerCreateRequest.cs:47), GitHubUrl (SpeakerCreateRequest.cs:50), and WebsiteUrl (SpeakerCreateRequest.cs:53).
  • Why it's built this way: required plus init gives compile-time enforcement of the minimum payload while leaving the rest optional, and collapsing request and command into one type keeps a simple create slice to a single message (contrast the child-mutation flows, where the controller builds a distinct command record such as AddSpeakerCategoryItemCommand).
  • Where it's used: bound by SpeakersController on the SpeakersManage-gated POST /Speakers (SpeakersController.cs:308-317); validated by SpeakerCreateRequestValidator; translated to a domain entity by SpeakerCreateRequestMapper; handled by CreateSpeakerHandler. Its update-side sibling on the same shape interface is SpeakerUpdateRequest.
  • Caveats / not-in-source: FullName is required on the contract but never reaches the domain. Speaker computes FullName => $"{FirstName} {LastName}" (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61) and SpeakerCreateRequestMapper does not pass it to the factory, so a caller-supplied value is accepted and discarded. It is the only member of this record the mapper drops; every other one, the four profile links included, is forwarded (SpeakerCreateRequestMapper.cs:19-31). Nothing on this type says so.

SpeakerQuestionAnswerDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.DTOs · MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerQuestionAnswerDTOMapper.cs:12 · Level 8 · class (sealed partial)

  • What it is: the Mapperly mapper for SpeakerQuestionAnswer to SpeakerQuestionAnswerDTO, the speaker's answers to the conference's profile questions. Structurally identical to SpeakerCategoryItemDTOMapper: the [Mapper] attribute (SpeakerQuestionAnswerDTOMapper.cs:11), the partial MapToDTO (SpeakerQuestionAnswerDTOMapper.cs:16), and the hand-written MapToDTOs (SpeakerQuestionAnswerDTOMapper.cs:19-23).
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over the answer entity, its DTO, and SpeakerQuestionAnswerIdentifierType (SpeakerQuestionAnswerDTOMapper.cs:13), plus Mapperly.
  • Concept introduced: none new; see SpeakerCategoryItemDTOMapper for the generated-mapper mechanism and why MapToDTOs is re-declared on the class.
  • Where it's used: this is the one mapper in the speaker family with no handler and no query service of its own. It reaches the wire only through composition: SpeakerDTOMapper holds it as a [UseMapper] field (SpeakerDTOMapper.cs:26-27) and the generator calls it while filling SpeakerDTO.SpeakerQuestionAnswers (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:103). Registration is by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133), which is why no explicit line exists for it while SpeakerCategoryItem gets one at DependencyInjection.cs:118-119. The entity's navigation populator is registered on its own at DependencyInjection.cs:123, and the comment above it records why that pair is uneven: SpeakerQuestionAnswer has no query service today, and registering the populator future-proofs the one that would be added alongside it (DependencyInjection.cs:121-122). Covered by SpeakerQuestionAnswerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerQuestionAnswerDTOMapperTests.cs).

AddSpeakerCategoryItemCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.AddSpeakerCategoryItem · MMCA.ADC.Conference.Application/Speakers/UseCases/AddSpeakerCategoryItem/AddSpeakerCategoryItemCommandValidator.cs:8 · Level 9 · class (sealed)

  • What it is: the FluentValidation validator for AddSpeakerCategoryItemCommand. It asserts one thing: the caller actually supplied a category item.
  • Depends on: FluentValidation's AbstractValidator<T> (AddSpeakerCategoryItemCommandValidator.cs:1, AddSpeakerCategoryItemCommandValidator.cs:8) and the CategoryItemIdentifierType alias.
  • Concept introduced, the Validating decorator stage: [Rubric §24, Forms, Validation and UX Safety] assesses whether bad input is rejected before it reaches business logic, and [Rubric §6, CQRS & Event-Driven Design] assesses whether that happens uniformly. The handler never calls this class. ValidatingCommandDecorator<TCommand, TResult> runs every registered validator for the command type outside the transaction, because it is registered after the transactional decorator and therefore wraps it (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:131-133, ADR-014), so a malformed command costs no database work. Registration is by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133): dropping a validator file next to the command is the entire wiring step, which is the vertical-slice payoff, [Rubric §5, Vertical Slice].
  • Walkthrough: an expression-bodied constructor with a single rule (AddSpeakerCategoryItemCommandValidator.cs:10-13): RuleFor(x => x.CategoryItemId).NotEqual(default(CategoryItemIdentifierType)) with the message "Category item ID is required." Because the identifier alias is a value type (an int), default is the "not supplied" sentinel that model binding produces for a missing JSON field, so this rule is what turns a silently omitted field into a 400 rather than a lookup miss deeper in.
  • Why it's built this way: the validator covers only what can be judged from the message itself. Whether the association is a duplicate is decided against loaded state in the aggregate (Speaker.AddSpeakerCategoryItem at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:318-325) rather than restated here. [Rubric §4, Domain-Driven Design]: the invariant stays in the aggregate; the validator only guards the shape.
  • Where it's used: resolved by the Validating decorator for AddSpeakerCategoryItemCommand; covered directly by AddSpeakerCategoryItemCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCommandValidatorTests.cs:6, instantiating the validator at SpeakerCommandValidatorTests.cs:8).

AddSpeakerCategoryItemHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.AddSpeakerCategoryItem · MMCA.ADC.Conference.Application/Speakers/UseCases/AddSpeakerCategoryItem/AddSpeakerCategoryItemHandler.cs:15 · Level 9 · class (sealed partial)

  • What it is: the handler for AddSpeakerCategoryItemCommand. It writes no workflow of its own: it subclasses the framework's shared add-a-child workflow and fills in five hooks.
  • Depends on: AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO> closed over the command, Speaker, SpeakerIdentifierType, SpeakerCategoryItem, and SpeakerCategoryItemDTO (AddSpeakerCategoryItemHandler.cs:19); IUnitOfWork, passed straight through to the base (AddSpeakerCategoryItemHandler.cs:16); SpeakerCategoryItemDTOMapper as a concrete type (AddSpeakerCategoryItemHandler.cs:17); Result; and Microsoft.Extensions.Logging.
  • Concept introduced, the template-method handler base: [Rubric §2, Design Patterns] assesses whether a repeated workflow is factored into one place. The load-delegate-save-map sequence every add-a-child handler used to write by hand lives once, in AddChildEntityHandlerBase.HandleAsync (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:102-126): resolve the parent repository from the unit of work, load it with the declared includes and tracking, fail with Error.NotFound stamped with the handler name and the parent type name when it is gone (ChildEntityHandlerBase.cs:111-112), call Apply, short-circuit on a failed invariant (ChildEntityHandlerBase.cs:114-116), save only after that (ChildEntityHandlerBase.cs:118), then log and map. [Rubric §1, SOLID]: the base owns the sequence, the subclass owns the vocabulary, and the base stays abstract so Scrutor keeps registering the concrete handler and the decorator pipeline keeps wrapping it (ChildEntityHandlerBase.cs:17-23).
  • Concept introduced, loading the children the invariant needs: [Rubric §4, Domain-Driven Design] assesses whether application code mutates child entities directly. It does not here: Apply is one expression, parent.AddSpeakerCategoryItem(command.SpeakerCategoryItemId, command.CategoryItemId) (AddSpeakerCategoryItemHandler.cs:30-31), and the aggregate is where the duplicate rule, the child factory call, and the SpeakerCategoryItemChanged domain event live (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:314-338). Includes is abstract on the base for exactly this reason (ChildEntityHandlerBase.cs:14-19): naming the join collection has to be a deliberate act, and the comment above the override in this file says what breaks without it (AddSpeakerCategoryItemHandler.cs:21-22). Without the include, the duplicate check at Speaker.cs:318 runs against an empty in-memory list and a double submit surfaces as a raw unique-index 409 instead of a worded invariant failure. Tracking is the other half, defaulted to true on the base because a no-tracking load would make the save a silent no-op (ChildEntityHandlerBase.cs:46-50). [Rubric §8, Data Architecture]: the in-memory check is still backed at the database level by a unique index on (SpeakerId, CategoryItemId) filtered to non-deleted rows (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerCategoryItemConfiguration.cs:30-32), so a race that beats the check still cannot write a duplicate row.
  • Walkthrough: a primary constructor taking unitOfWork, the concrete dtoMapper, and a typed ILogger, forwarding only the unit of work to the base (AddSpeakerCategoryItemHandler.cs:15-19). Then five members, and nothing else.
    • Includes returns [nameof(Speaker.SpeakerCategoryItems)] (AddSpeakerCategoryItemHandler.cs:24).
    • ParentId pulls command.SpeakerId (AddSpeakerCategoryItemHandler.cs:27).
    • Apply calls the aggregate method and returns its Result<SpeakerCategoryItem> unchanged (AddSpeakerCategoryItemHandler.cs:30-31), so a duplicate association reaches the client worded as the aggregate worded it (Speaker.CategoryItem.Duplicate, Speaker.cs:320-325).
    • MapChild is the one-line call into the module's own child mapper (AddSpeakerCategoryItemHandler.cs:34). The base takes a MapChild hook rather than an injected IEntityDTOMapper because the DTO belongs to the CHILD entity, whose identifier type is not the parent's (ChildEntityHandlerBase.cs:20-24).
    • LogAdded forwards to the source-generated LogCategoryItemAdded (AddSpeakerCategoryItemHandler.cs:37-38, declared at AddSpeakerCategoryItemHandler.cs:40-41), which is why the class is partial. The base calls it only after a successful save (ChildEntityHandlerBase.cs:122).
  • Why it's built this way: the handler is pure vocabulary because the surrounding layers already own everything else: validation and cache eviction by the decorators (ADR-014), the workflow by the base class, the rules by the aggregate. [Rubric §13, Observability and Operability]: logging goes through a [LoggerMessage] partial, allocation-free and compile-checked, and the base gives it exactly one call site so a log can never be emitted for a write that did not commit.
  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133, which drives the framework's command-handler scan at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:242) and injected into SpeakerCategoryItemsController as ICommandHandler<AddSpeakerCategoryItemCommand, Result<SpeakerCategoryItemDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:50). Its counterpart is RemoveSpeakerCategoryItemHandler. Covered by AddSpeakerCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/AddSpeakerCategoryItemHandlerTests.cs).
  • Caveats / not-in-source: the base exposes two more hooks this handler leaves alone, HandlerName (defaulting to the concrete type name, ChildEntityHandlerBase.cs:44) and the post-commit OnAddedAsync (ChildEntityHandlerBase.cs:98-99). Neither is overridden here, so nothing in this file records that they exist.

QuestionCreateRequestMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Create · MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestMapper.cs:11 · Level 9 · class (sealed)

  • What it is: the adapter that turns a validated QuestionCreateRequest into a Question entity by calling the aggregate's static factory.
  • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Question, QuestionCreateRequest, and QuestionIdentifierType (QuestionCreateRequestMapper.cs:11-12); Question; Result.
  • Concept introduced, the request mapper as the only door into a factory: [Rubric §4, Domain-Driven Design] assesses whether an entity can be constructed in an invalid state. It cannot: Question.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:70) combines four invariant checks before it allocates anything (Question.cs:79-85) and raises QuestionChanged on success (Question.cs:94), returning a Result<Question> throughout. A bad request therefore becomes an error list, never a half-built object. Unlike a DTO mapper, which copies fields outward, a request mapper translates inward and is allowed to fail. Note the class carries no [Mapper] attribute: this is hand-written mapping, not Mapperly generation (contrast SpeakerCategoryItemDTOMapper). [Rubric §3, Clean Architecture]: keeping it in its own class is what lets CreateQuestionHandler and its base depend on the generic interface instead of the factory signature.
  • Walkthrough: CreateEntityAsync (QuestionCreateRequestMapper.cs:15) null-guards the request (QuestionCreateRequestMapper.cs:17), then returns Task.FromResult(Question.Create(...)) (QuestionCreateRequestMapper.cs:19-26): the work is synchronous and the Task exists only to satisfy the async interface. Two things in that call are worth reading twice. The nullable QuestionEntity and QuestionType are forced with ! (QuestionCreateRequestMapper.cs:22-23), which does not make them non-null; it hands a possible null to invariants that reject it against a closed list (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:71-72, QuestionInvariants.cs:86-87), so an omitted field becomes a worded invariant error rather than a null-reference exception. And questionSource is hard-coded to "User" (QuestionCreateRequestMapper.cs:26), never taken from the request: an API-created question can never claim to have come from Sessionize, the other member of the closed source list (QuestionInvariants.cs:37).
  • Why it's built this way: delegating every field check to the factory keeps validation in the domain instead of duplicated in the Application layer, and the generic create pipeline can drive any aggregate through the same contract (ADR-001). Pinning the source server-side is the same instinct as never taking an identity from a request body: provenance is not a caller's field to set.
  • Where it's used: resolved as IEntityRequestMapper<Question, QuestionCreateRequest, QuestionIdentifierType> and constructor-injected into CreateQuestionHandler (CreateQuestionHandler.cs:22), which forwards it to its base; the base invokes it at MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:90. Registered by the framework's request-mapper scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:217), driven from the module at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133.

QuestionCreateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Create · MMCA.ADC.Conference.Application/Questions/UseCases/Create/QuestionCreateRequestValidator.cs:7 · Level 9 · class (sealed)

  • What it is: the FluentValidation validator for QuestionCreateRequest. It declares no rules of its own; it includes one shared rule set.
  • Depends on: FluentValidation's AbstractValidator<T> (QuestionCreateRequestValidator.cs:1, QuestionCreateRequestValidator.cs:7) and QuestionTextRules<T> (QuestionCreateRequestValidator.cs:2).
  • Concept reinforced, rule composition with Include: [Rubric §15, Best Practices & Code Quality] assesses whether the same constraint is written once. Rather than repeat a required-plus-max-length rule in the create validator and again in the update validator, both Include the same generic rule set, parameterized by a property selector (QuestionCreateRequestValidator.cs:10). Include merges the included validator's rules into this one as though they were declared inline, so composition costs nothing at validation time. [Rubric §24, Forms, Validation and UX Safety] covers what the rules produce: QuestionTextRules<T> attaches NotEmpty and MaximumLength(QuestionInvariants.QuestionTextMaxLength) with stable error codes Question.QuestionText.Required and Question.QuestionText.MaxLength (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Questions/Validation/QuestionValidationRules.cs:17-18). The ceiling is 1000 characters, and it is one constant reached through two hops: QuestionInvariants.QuestionTextMaxLength forwards to QuestionDTO.QuestionTextMaxLength (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:16, value declared at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Questions/QuestionDTO.cs:17), and the domain invariant enforces the same constant (QuestionInvariants.cs:62), so the form limit, the domain limit, and the column width cannot drift apart.
  • Walkthrough: a sealed class whose whole body is an expression-bodied constructor (QuestionCreateRequestValidator.cs:9-10) calling Include(new QuestionTextRules<QuestionCreateRequest>(p => p.QuestionText)).
  • Why it's built this way: extracting field rules into shared generic classes is the module-wide convention; add a constraint once and every request type that includes the rule set picks it up.
  • Where it's used: discovered by the module's validator scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133) and run by ValidatingCommandDecorator<TCommand, TResult> ahead of CreateQuestionHandler. Covered by QuestionCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/Validation/QuestionCreateRequestValidatorTests.cs).
  • Caveats / not-in-source: only QuestionText is validated here. QuestionEntity, QuestionType, and QuestionSource are left entirely to the domain invariants inside Question.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:79-85), so an invalid or missing value arrives as an invariant failure rather than a field-level validation error.

SpeakerCreateRequestMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestMapper.cs:11 · Level 9 · class (sealed)

  • What it is: the adapter that turns a validated SpeakerCreateRequest into a Speaker entity by calling the aggregate's static factory.
  • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Speaker, SpeakerCreateRequest, and SpeakerIdentifierType (SpeakerCreateRequestMapper.cs:11-12); Speaker; Result.
  • Concept reinforced, the request mapper as the only door into a factory: see QuestionCreateRequestMapper for the pattern. Speaker.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:116-171) parses the optional email into an Email value object and bails on a malformed address (Speaker.cs:130-137), combines the first- and last-name invariants with Result.Combine so both failures surface at once (Speaker.cs:139-143), assigns the client-supplied id or generates one (Speaker.cs:161), and raises SpeakerChanged (Speaker.cs:168). The id fallback carries its own scar: the comment records that the previous id!.Value threw "Nullable object must have a value" and killed both Conference's startup seeding and every organizer create (Speaker.cs:156-160). [Rubric §4, Domain-Driven Design], [Rubric §15, Best Practices and Code Quality].
  • Walkthrough: CreateEntityAsync (SpeakerCreateRequestMapper.cs:15) null-guards (SpeakerCreateRequestMapper.cs:17), then returns Task.FromResult(Speaker.Create(...)) (SpeakerCreateRequestMapper.cs:19-31). Twelve of the factory's parameters are filled from the request (Id, FirstName, LastName, Email, Bio, TagLine, ProfilePicture, IsTopSpeaker, TwitterHandle, LinkedInUrl, GitHubUrl, WebsiteUrl); only FullName is dropped, because the entity computes it (Speaker.cs:61). The method is synchronous work behind an async signature: Task.FromResult satisfies the interface without an allocation-heavy state machine.
  • Why it's built this way: delegating every field check to the factory keeps validation in the domain instead of duplicated in the Application layer, and the generic create pipeline can drive any aggregate through the same contract (ADR-001).
  • Where it's used: constructor-injected into CreateSpeakerHandler as IEntityRequestMapper<Speaker, SpeakerCreateRequest, SpeakerIdentifierType> (CreateSpeakerHandler.cs:17) and forwarded to its base (CreateSpeakerHandler.cs:20-21), which invokes it at MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:90; registered by the framework's request-mapper scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:217), driven from the module at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133.
  • Caveats / not-in-source: the factory's id parameter is SpeakerIdentifierType?, but the request's Id is the non-nullable alias, so this path always passes a value and the Guid.NewGuid() fallback at Speaker.cs:161 is unreachable from it. A POST /Speakers body that omits Id therefore creates a speaker whose key is Guid.Empty rather than a fresh GUID. The null-id branch is reached only by callers that pass id: null explicitly, which is what the sample-data seeder does (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:209-210); the Sessionize import supplies the Sessionize GUID instead (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:126). Nothing in this file or the request records that expectation.

SpeakerCreateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/SpeakerCreateRequestValidator.cs:7 · Level 9 · class (sealed)

  • What it is: the FluentValidation validator for SpeakerCreateRequest. It declares no rules of its own; its whole body is one Include of the module's shared speaker rule set.
  • Depends on: FluentValidation's AbstractValidator<T> (SpeakerCreateRequestValidator.cs:1, SpeakerCreateRequestValidator.cs:7) and SpeakerFieldRules<T> (SpeakerCreateRequestValidator.cs:2, SpeakerCreateRequestValidator.cs:10).
  • Concept reinforced, rule composition with Include, one layer deeper: QuestionCreateRequestValidator includes a single field rule set parameterized by a selector. This validator includes a rule set that is itself nothing but six Include calls (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:117-125), and it needs no selectors at all, because SpeakerFieldRules<T> is constrained to ISpeakerFieldsRequest (SpeakerValidationRules.cs:115) and reaches the members through the interface. [Rubric §15, Best Practices & Code Quality] assesses whether a constraint is written once: the create and update speaker validators share one list, so adding a rule is one edit in one file. The list itself is SpeakerFirstNameRules<T> and SpeakerLastNameRules<T>, both extensions of the framework's RequiredStringRules<T> (SpeakerValidationRules.cs:12-28); SpeakerEmailRules<T>, which applies the shared EmailRules<T> only when a value is supplied (SpeakerValidationRules.cs:41-47); and three URL rule sets that apply AbsoluteUrlRules<T> the same conditional way (SpeakerValidationRules.cs:57-105).
  • Concept introduced, a validation rule that is a security control: [Rubric §26, Front-End Security] assesses whether stored values that reach the browser as executable targets are constrained at the point of write. The three URL rule sets exist for that reason, and their doc comments say so: the scheme check keeps a stored link from carrying a javascript: or data: target, because the speaker pages put the value straight into a link (SpeakerValidationRules.cs:51-55, SpeakerValidationRules.cs:69-74, SpeakerValidationRules.cs:88-93). [Rubric §11, Security] reaches the same code from the server side: a request that never passes validation never persists the payload.
  • Walkthrough: a sealed class whose whole body is an expression-bodied constructor (SpeakerCreateRequestValidator.cs:9-10) calling Include(new SpeakerFieldRules<SpeakerCreateRequest>()). The ceilings all come from the domain rather than from the validator: SpeakerInvariants.FirstNameMaxLength and LastNameMaxLength are 200, EmailMaxLength is 255, and the three URL lengths are 2000 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/SpeakerInvariants.cs:16, SpeakerInvariants.cs:19, SpeakerInvariants.cs:22, SpeakerInvariants.cs:34, SpeakerInvariants.cs:37, SpeakerInvariants.cs:40), each forwarding to the constant on SpeakerDTO (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:21-52), which is also what the name invariants enforce (SpeakerInvariants.cs:48, SpeakerInvariants.cs:53).
  • Why it's built this way: extracting field rules into shared generic classes is the module-wide convention, and hoisting the whole list onto an interface-constrained rule set removes the last piece of duplication, the six selector lambdas that the create and the update validator would otherwise each write out.
  • Where it's used: discovered by the module's validator scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133) and run by ValidatingCommandDecorator<TCommand, TResult> ahead of CreateSpeakerHandler. Covered by SpeakerCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCreateRequestValidatorTests.cs), with the rule sets themselves covered by SpeakerValidationRulesTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerValidationRulesTests.cs).
  • Caveats / not-in-source: Bio, TagLine, ProfilePicture, and TwitterHandle are not on ISpeakerFieldsRequest and are validated nowhere in this layer, even though SpeakerInvariants declares max lengths for three of them (SpeakerInvariants.cs:25, SpeakerInvariants.cs:28, SpeakerInvariants.cs:31). An over-long tagline is therefore caught by the database column, not by a worded validation error.

SpeakerDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.DTOs · MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:17 · Level 9 · class (sealed partial)

  • What it is: the mapper for the Speaker aggregate itself. It composes the two child mappers, and it is the single place where BR-66 redacts speaker email from anyone who is not an organizer.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Speaker / SpeakerDTO / SpeakerIdentifierType (SpeakerDTOMapper.cs:21); the two sibling mappers SpeakerCategoryItemDTOMapper and SpeakerQuestionAnswerDTOMapper; ICurrentUserService; the Email value object; RoleNames; and Mapperly (SpeakerDTOMapper.cs:1-21).
  • Concept introduced, a redacting mapper, and why the redaction lives here: [Rubric §11, Security] assesses whether a PII rule is enforced at one chokepoint rather than at each call site, and [Rubric §30, Compliance, Privacy and Data Governance] assesses whether personal data has a stated handling rule. MapToDTO does not expose the generated mapping directly. It calls the private generated method and then decides: currentUserService.IsInRole(RoleNames.Organizer) ? dto : dto with { Email = null } (SpeakerDTOMapper.cs:33-36). Because every speaker read path in the module goes through this one mapper (SpeakerEntityQueryService.cs:19, CreateSpeakerHandler.cs:18, UpdateSpeakerHandler.cs:20), there is no endpoint that can accidentally return a speaker email to the public. That single-chokepoint property is also what makes the framework's inherited CSV export a hole worth patching separately: it streams past the DTO mapper entirely, which is why SpeakersController overrides the export action, gates it on SpeakersManage, and denies non-privileged callers outright (BR-239, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:281-304). Reading the mapper alone would leave you believing the rule was airtight; reading the pair shows where it needed help.
  • Concept introduced, mapper composition with [UseMapper]: each injected child mapper is stored in a private field annotated [UseMapper] (SpeakerDTOMapper.cs:23-27). That attribute tells the Mapperly generator: when you need to map a SpeakerCategoryItem to a SpeakerCategoryItemDTO while filling this type, call that mapper instead of generating a second, private copy of the same mapping. The payoff is that SpeakerDTO's two child collections (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:100, SpeakerDTO.cs:103) are filled by exactly the same code the child endpoints use, so a speaker read and a GET /SpeakerCategoryItems read can never disagree about a child's shape. [Rubric §2, Design Patterns]: composition over duplication, expressed declaratively. [Rubric §15, Best Practices & Code Quality]: adding a field to a child DTO is one edit, not three.
  • Walkthrough: four methods plus the two fields.
    • The primary constructor takes the two child mappers and ICurrentUserService (SpeakerDTOMapper.cs:17-20), assigned to the [UseMapper] fields (SpeakerDTOMapper.cs:23-27).
    • MapToDTO (SpeakerDTOMapper.cs:30-37) is hand-written, not generated: null guard, call the generated mapping, apply the BR-66 redaction with a with expression on the record DTO.
    • MapToDTOGenerated (SpeakerDTOMapper.cs:46) is the private partial the generator fills. Making the generated method private and wrapping it is the mechanism that lets the redaction be unskippable; a caller cannot reach the unredacted projection.
    • MapToDTOs (SpeakerDTOMapper.cs:40-44) maps a collection through the same public MapToDTO, so the rule applies per row on list reads too.
    • NullableEmailToString (SpeakerDTOMapper.cs:49) is a private conversion helper Mapperly picks up to turn the Email value object into the DTO's string? (SpeakerDTO.cs:70). A value object on the entity and a plain string on the contract is exactly the kind of gap that would otherwise be a build error.
  • Why it's built this way: children are mapped by their owners' mappers, so the DTO graph is assembled from single-purpose pieces the DI container already has (ADR-001). What the child collections actually contain at map time is decided earlier, by SpeakerNavigationPopulator (ADR-002): this mapper copies what was loaded and never triggers a query itself.
  • Where it's used: injected as a concrete type into CreateSpeakerHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Create/CreateSpeakerHandler.cs:18) and UpdateSpeakerHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:20) for the write-path response DTO, and into SpeakerEntityQueryService (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:19), the speaker-specific query service registered at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:77, which every speaker read endpoint goes through (ADR-034). Covered by SpeakerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerDTOMapperTests.cs).
  • Caveats / not-in-source: the redaction depends on ICurrentUserService resolving a real caller. In a background or system context with no principal, IsInRole returning false means the mapper redacts, which fails closed; that is the safe direction, but nothing in this file states it as an intended behavior.

CreateSpeakerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.Create · MMCA.ADC.Conference.Application/Speakers/UseCases/Create/CreateSpeakerHandler.cs:15 · Level 10 · class (sealed partial)

  • What it is: the handler that creates a speaker, and the smallest handler in this unit. Its entire body is a logging hook and the [LoggerMessage] partial that hook calls; the workflow comes from the base class.
  • Depends on: CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> closed over SpeakerCreateRequest, Speaker, SpeakerIdentifierType, and SpeakerDTO (CreateSpeakerHandler.cs:20-21); IUnitOfWork (CreateSpeakerHandler.cs:16); IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, satisfied by SpeakerCreateRequestMapper (CreateSpeakerHandler.cs:17); SpeakerDTOMapper as a concrete type (CreateSpeakerHandler.cs:18); Microsoft.Extensions.Logging. All four constructor parameters except the logger are forwarded to the base.
  • Concept introduced, the shared create workflow: [Rubric §2, Design Patterns] and [Rubric §15, Best Practices & Code Quality]. CreateEntityHandlerBase owns the whole sequence in CreateCoreAsync (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:77-103): run the optional PrepareAsync step and stop on its failure (CreateEntityHandlerBase.cs:84-86), map the request through the entity factory and forward the mapper's errors verbatim (CreateEntityHandlerBase.cs:90-92), resolve the repository from the unit of work rather than injecting it (CreateEntityHandlerBase.cs:95, and the remark at CreateEntityHandlerBase.cs:25-28 explains that only the unit of work knows which physical data source an entity resolves to), persist with AddAsync plus SaveChangesAsync (CreateEntityHandlerBase.cs:138-139), then log, run the post-commit hook, and return Result.Success(dtoMapper.MapToDTO(entity)) (CreateEntityHandlerBase.cs:99-102). Every await in that path carries ConfigureAwait(false) (ADR-049). The base stays abstract deliberately, so the concrete subclass remains the registered handler that the scan discovers and the decorators wrap (CreateEntityHandlerBase.cs:17-23).
  • Concept reinforced, the create response obeys the same rules as a read: [Rubric §5, Vertical Slice]: the request IS the command, so the base closes ICommandHandler over SpeakerCreateRequest itself (CreateEntityHandlerBase.cs:46) and the four types of the slice sit in one folder. [Rubric §11, Security] reaches it through the projection: the base maps the response with the injected SpeakerDTOMapper, which blanks the speaker's email for non-organizers (BR-66, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:35-36), so even the create response is redacted by the same rule as every read, not only the list endpoints.
  • Walkthrough: the primary constructor (CreateSpeakerHandler.cs:15-21) and two members.
    • LogCreated (CreateSpeakerHandler.cs:24) overrides the base's no-op hook and forwards to the generated partial, recording the id and the computed full name. The base's default does nothing on purpose, because logging is per-module vocabulary and the base only supplies the call site (CreateEntityHandlerBase.cs:142-151).
    • LogSpeakerCreated (CreateSpeakerHandler.cs:26-27) is the [LoggerMessage] partial, which is why the class is partial. [Rubric §13, Observability and Operability].
    • Nothing else. PrepareAsync, PersistAsync, HandleAsync, and OnCreatedAsync are all left at their base defaults, which is the contrast worth holding onto against CreateQuestionHandler: a speaker id is a client-assigned GUID, so no id allocation and no collision retry are needed here.
  • Why it's built this way: validation, cache invalidation, and transaction scope are all handled by the pipeline decorators wrapped around this handler (declared by the markers on SpeakerCreateRequest), and the create sequence itself is the framework's, which is why the module-specific part of a create reduces to a log message (ADR-014). [Rubric §1, SOLID]: the handler's one reason to change is the use case's own vocabulary.
  • Where it's used: registered by the framework's command-handler scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:242, driven from MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into SpeakersController as ICommandHandler<SpeakerCreateRequest, Result<SpeakerDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:49) and dispatched by its POST /Speakers override, which delegates to the base controller and then evicts the conference:speakers and conference output-cache tags (SpeakersController.cs:308-317). The action is gated on the SpeakersManage permission (SpeakersController.cs:309). Covered by CreateSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/CreateSpeakerHandlerTests.cs).
  • Caveats / not-in-source: nothing here guards against a caller re-submitting an id that already exists; the insert simply fails on the primary key and surfaces through the shared exception handling. The Sessionize import path relies on that, since it supplies the Sessionize GUID as the id (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SpeakerSyncStrategy.cs:126), but no comment in this file records the expectation.

CreateQuestionHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Questions.UseCases.Create · MMCA.ADC.Conference.Application/Questions/UseCases/Create/CreateQuestionHandler.cs:19 · Level 14 · class (sealed partial)

  • What it is: the handler that creates a question. It is the same CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> workflow CreateSpeakerHandler uses, with server-controlled id allocation bolted on, and it is the only handler in this unit that retries itself.
  • Depends on: the create base closed over QuestionCreateRequest, Question, QuestionIdentifierType, and QuestionDTO (CreateQuestionHandler.cs:25-26); IUnitOfWork; IServiceScopeFactory (Microsoft.Extensions.DependencyInjection, CreateQuestionHandler.cs:21); IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, satisfied by QuestionCreateRequestMapper; QuestionDTOMapper; QuestionInvariants; Result and Error; logging (CreateQuestionHandler.cs:19-26).
  • Concept introduced, application-side key allocation in a reserved range, and the retry that makes it safe: [Rubric §8, Data Architecture] assesses whether a key strategy is deliberate and collision-proof. The question id space is shared with an external system: Sessionize assigns ids to imported questions, so the database's identity column cannot be trusted to stay out of the way. The module reserves 999_999_000 to 999_999_999 for user-created questions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:40, QuestionInvariants.cs:43), and the handler allocates max + 1 inside it. Two details make that allocation honest. First, the range query passes ignoreQueryFilters: true (CreateQuestionHandler.cs:75), so a soft-deleted question still reserves its id and a re-created question never reuses a deleted key (ADR-005). Second, max + 1 computed outside a lock is a race by construction, so the handler expects to lose it occasionally and retries. [Rubric §29, Resilience and Business Continuity]: the failure mode is anticipated in code rather than left to the caller.
  • Concept introduced, the two hooks a retrying create overrides: the base was written with this handler's shape in mind and says so (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:29-36): a create path that computes its own key overrides HandleAsync to wrap CreateCoreAsync in a retry loop, and overrides PrepareAsync to recompute the id per attempt. CreateCoreAsync takes the unit of work as a parameter (CreateEntityHandlerBase.cs:77-80) precisely so a retry can run against a fresh DI scope's unit of work while reusing the whole workflow. [Rubric §1, SOLID]: the variation point is a parameter and two virtuals, not a fork of the sequence.
  • Walkthrough: the primary constructor adds IServiceScopeFactory and the concrete QuestionDTOMapper to the usual dependencies (CreateQuestionHandler.cs:19-26), and MaxManualIdAttempts is a const int of 3 (CreateQuestionHandler.cs:29).
    • HandleAsync (CreateQuestionHandler.cs:32-60) overrides the base's one-shot version with a bounded retry loop. The first attempt runs against the ambient unit of work exposed by the base as UnitOfWork (CreateQuestionHandler.cs:45-46). Every later attempt creates an await using DI scope and resolves a fresh IUnitOfWork from it (CreateQuestionHandler.cs:50-52), and the comment explains why that is not optional: the ambient DbContext still tracks the failed insert, so a clean context is required for the recomputed id to persist.
    • The catch is an exception filter, not a blanket catch: it re-enters the loop only while attempt < MaxManualIdAttempts and only for a unique-key violation (CreateQuestionHandler.cs:54-58), logging a warning through the generated LogManualIdCollision. Anything else propagates.
    • PrepareAsync (CreateQuestionHandler.cs:63-87) is the per-attempt id allocation: resolve the repository off the passed-in unit of work (CreateQuestionHandler.cs:68), read every question in the manual range (CreateQuestionHandler.cs:72-76), take max + 1 or the range start when the range is empty (CreateQuestionHandler.cs:78-80), fail with a plain Error.Failure when the range is exhausted (CreateQuestionHandler.cs:82-83), and otherwise return the request rewritten with a with expression (CreateQuestionHandler.cs:86). The base then maps that rewritten request, so the caller's id is gone before the factory ever sees it.
    • LogCreated (CreateQuestionHandler.cs:90) is the same one-line hook override as in CreateSpeakerHandler.
    • IsUniqueKeyViolation (CreateQuestionHandler.cs:97-106) walks the whole InnerException chain looking for the text "duplicate key". The comment states the constraint that forces this (CreateQuestionHandler.cs:92-96): the Application layer cannot reference EF Core types, so detection is message-based, and both SQL Server errors 2601 and 2627 carry that phrase. [Rubric §3, Clean Architecture]: the layer rule is upheld, and the cost is paid openly, in a documented string match rather than a hidden provider reference.
  • Why it's built this way: the class comment contrasts this slice with the session create path (CreateQuestionHandler.cs:36-38): there is no explicit-id branch here, because this handler always overrides the caller id, which is precisely what makes every attempt retryable. A handler that sometimes honored a caller id could not blindly recompute on collision.
  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into QuestionsController as ICommandHandler<QuestionCreateRequest, Result<QuestionDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Questions/QuestionsController.cs:37) and dispatched by its POST /Questions override, which delegates to the base controller and then evicts the conference:questions output-cache tag (QuestionsController.cs:95-103). The whole controller is gated on the QuestionsManage permission except the explicitly anonymous read actions (QuestionsController.cs:34, QuestionsController.cs:46). Covered by CreateQuestionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/UseCases/CreateQuestionHandlerTests.cs).
  • Caveats / not-in-source: the range read materializes every manual-range question on every create attempt to compute one maximum. At the conference's question volume that is negligible, but nothing in the file bounds it, and no comment records the trade-off.

LinkUserToSpeakerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.LinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/LinkUser/LinkUserToSpeakerHandler.cs:25 · Level 14 · class (sealed partial)

  • What it is: the handler for LinkUserToSpeakerCommand. It updates the Conference side of a bidirectional link that spans two databases, and raises the event that updates the other side.
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>, the bare-Result flavor of the shared load-mutate-save workflow, closed over the command, Speaker, and SpeakerIdentifierType (LinkUserToSpeakerHandler.cs:28); IUnitOfWork (LinkUserToSpeakerHandler.cs:26); SpeakerLinkedToUser; Result and Error; Microsoft.Extensions.Logging. Note what is not injected: there is no publisher service, because the event is raised on the aggregate; and there is no DTO mapper, because a link returns no body.
  • Concept introduced, cross-context coordination captured in the same transaction: [Rubric §6, CQRS and Event-Driven] and [Rubric §7, Microservices Readiness]. Conference and Identity own separate databases (ADR-006), so there is no foreign key between Speaker and User and consistency has to flow through events. The load-bearing detail is the ordering: entity.AddDomainEvent(new SpeakerLinkedToUser(...)) runs inside MutateAsync (LinkUserToSpeakerHandler.cs:63), and the base saves only after MutateAsync returns (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:294-303), so the outbox row is written inside the same transaction as the link (ADR-003). The class comment states the failure this removed (LinkUserToSpeakerHandler.cs:13-18): with a post-save publish, a crash could commit the Conference-side link and lose the event that sets User.LinkedSpeakerId. The remark right below it makes the dependence on the base explicit (LinkUserToSpeakerHandler.cs:20-24): the whole mutation step, BR-208 cross-check included, runs before that one save. The OutboxProcessor later routes the row to the registered IMessageBus transport. This is also why the command carries ITransactional.
  • Walkthrough: a primary constructor (LinkUserToSpeakerHandler.cs:25-28) and three overrides.
    • EntityId names the aggregate to load, command.SpeakerId (LinkUserToSpeakerHandler.cs:31). The base does the load and returns Error.NotFound stamped with the handler name and the entity type name when the speaker is gone (MutateEntityHandlerBase.cs:281-283).
    • MutateAsync (LinkUserToSpeakerHandler.cs:34-67) is the whole use case. It null-guards both arguments (LinkUserToSpeakerHandler.cs:39-40), then runs the BR-208 uniqueness guard: it reaches a second repository through the base's exposed UnitOfWork (LinkUserToSpeakerHandler.cs:43), queries every speaker whose LinkedUserId equals the target user (LinkUserToSpeakerHandler.cs:44-47), and fails with Error.Invariant(code: "Speaker.UserAlreadyLinked", ...) if any OTHER speaker already holds that link (LinkUserToSpeakerHandler.cs:48-55). The s.Id != command.SpeakerId test is what makes re-linking the same pair a no-op rather than an error.
    • entity.LinkUser(command.UserId) (LinkUserToSpeakerHandler.cs:57) is the domain decision. The aggregate refuses a speaker that is already linked, returning Speaker.AlreadyLinked (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:272-281), so the handler owns only the cross-row rule and the entity owns its own. Only on success does the integration event go on the aggregate (LinkUserToSpeakerHandler.cs:58-64), and the domain Result is returned unchanged (LinkUserToSpeakerHandler.cs:66), so a rejection reaches the API with its error codes intact.
    • LogMutated (LinkUserToSpeakerHandler.cs:70-71) forwards to the generated LogUserLinkedToSpeaker (LinkUserToSpeakerHandler.cs:73-74); the base calls it only after a successful save (MutateEntityHandlerBase.cs:305).
  • Why it's built this way: splitting the rules (uniqueness across speakers in the handler, "already linked?" inside the aggregate) keeps each check where the data for it lives, and putting the event raise inside MutateAsync rather than after the save is a deliberate durability fix rather than a style choice.
  • Where it's used: registered by the Conference application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); invoked through the decorator pipeline by the PUT /Speakers/{id}/link action (SpeakersController at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:374-391), which returns 204 No Content and evicts the speaker output-cache tags on success (SpeakersController.cs:389-390). The emitted event is consumed on the Identity side to set User.LinkedSpeakerId. This organizer-driven path is also the deliberate fallback for speakers the automatic email match in UserRegisteredHandler cannot claim. Covered by LinkUserToSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/LinkUserToSpeakerHandlerTests.cs).
  • Caveats / not-in-source: the BR-208 guard is a read-then-write with no lock, so two concurrent links naming the same user could both pass it; nothing in this file says what settles that race. The base also supports an optimistic-concurrency stamp driven by a RowVersion hook (MutateEntityHandlerBase.cs:285-292, ADR-035), which this handler does not override, so the link endpoint is unconditional.

RemoveSpeakerCategoryItemCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.RemoveSpeakerCategoryItem · MMCA.ADC.Conference.Application/Speakers/UseCases/RemoveSpeakerCategoryItem/RemoveSpeakerCategoryItemCommand.cs:12 · Level 8 · record

  • What it is: the mirror image of AddSpeakerCategoryItemCommand, the message that detaches one category-item tag from a speaker. Two positional parameters: the owning SpeakerId and the SpeakerCategoryItemId of the join entity to remove (RemoveSpeakerCategoryItemCommand.cs:12-14).
  • Depends on: ICacheInvalidating, the only interface it implements (RemoveSpeakerCategoryItemCommand.cs:14); the Speaker domain type, referenced solely to build the cache prefix; and the SpeakerIdentifierType and SpeakerCategoryItemIdentifierType module aliases (ADR-048).
  • Concept reinforced, the remove command addresses the join row, not the tag: the second parameter is the identity of the association (SpeakerCategoryItem), not of the category item being untagged. That asymmetry with the Add command (which takes the CategoryItemId it wants to attach) is deliberate: once the association exists, the REST resource the client holds is the junction row, so a delete names it in the route and takes the speaker id from the query string (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:186-194). [Rubric §9, API and Contract Design] assesses whether the contract addresses the thing the caller actually has a handle on.
  • Walkthrough: a sealed record whose whole body is one expression-bodied member, CachePrefix => $"{typeof(Speaker).FullName}:" (RemoveSpeakerCategoryItemCommand.cs:16-17). That is the same prefix every other speaker write declares, so an untag flushes the whole cached speaker read surface rather than trying to surgically evict the one nested collection (ADR-026). Note the absence of ITransactional: the write touches a single aggregate in a single SaveChangesAsync, so there is nothing to keep atomic across contexts (contrast UnlinkUserFromSpeakerCommand, below).
  • Why it's built this way: the message declares its cross-cutting effects and the pipeline applies them (ADR-014); the handler stays free of cache code. [Rubric §12, Performance & Scalability].
  • Where it's used: constructed by the DELETE /SpeakerCategoryItems/{id} action of SpeakerCategoryItemsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:186-194, handler injected at SpeakerCategoryItemsController.cs:51), a controller gated on the SpeakersManage permission at the class level rather than per action (SpeakerCategoryItemsController.cs:47, ADR-020); handled by RemoveSpeakerCategoryItemHandler.

UnlinkUserFromSpeakerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.UnlinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerCommand.cs:12 · Level 8 · record

  • What it is: the inverse of LinkUserToSpeakerCommand: the organizer's message to detach an application account from a speaker profile (BR-209). It carries one parameter, SpeakerId (UnlinkUserFromSpeakerCommand.cs:12), because the link is one-to-one and the speaker already knows which user it holds.
  • Depends on: ICacheInvalidating and ITransactional, both implemented at UnlinkUserFromSpeakerCommand.cs:12; the Speaker type for the cache prefix; and the SpeakerIdentifierType alias.
  • Concept reinforced, the transaction marker as a statement about a cross-context write: the XML comment says exactly why ITransactional is on this record (UnlinkUserFromSpeakerCommand.cs:7-8): the Speaker-side unlink and the cross-context User update have to be atomic. In today's code that "cross-context update" is not a second database write but an outbox row: UnlinkUserFromSpeakerHandler raises SpeakerUnlinkedFromUser on the aggregate inside the mutation step, so the event lands in the same SaveChangesAsync as the unlink (ADR-003). Implementing ITransactional opts the message into TransactionalCommandDecorator<TCommand, TResult>, the innermost link of the registered command chain (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:131-137). [Rubric §6, CQRS and Event-Driven] assesses whether writes are explicit single-purpose messages that declare their delivery guarantees; [Rubric §29, Resilience]: the marker is what prevents a half-completed unlink from surviving a crash.
  • Walkthrough: a one-parameter sealed record with a single member, CachePrefix => $"{typeof(Speaker).FullName}:" (UnlinkUserFromSpeakerCommand.cs:14-15). The UserIdentifierType is absent from the contract on purpose: unlike the link direction, unlink does not need the caller to name the user, and asking for it would let a caller pass one that does not match the stored LinkedUserId.
  • Why it's built this way: markers push durability and cache eviction into the decorator pipeline, so the handler reads as domain orchestration only (ADR-014).
  • Where it's used: constructed by the DELETE /Speakers/{id}/link action of SpeakersController, gated on the SpeakersManage permission (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:393-409, handler injected at SpeakersController.cs:53, ADR-020); handled by UnlinkUserFromSpeakerHandler.

UserRegisteredHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Users.IntegrationEventHandlers · MMCA.ADC.Conference.Application/Users/IntegrationEventHandlers/UserRegisteredHandler.cs:45 · Level 8 · class (sealed partial)

  • What it is: the Conference-side subscriber to Identity's UserRegistered integration event. When someone registers an account, this handler tries to find the speaker profile that belongs to them and links the two (BR-207), so a speaker who signs up sees their own sessions without an organizer lifting a finger.
  • Depends on: ScopedIntegrationEventHandlerBase<TIntegrationEvent> closed over UserRegistered (UserRegisteredHandler.cs:48), which is where the IIntegrationEventHandler<in TIntegrationEvent> implementation actually lives; IServiceScopeFactory (BCL DI); IUnitOfWork and IEntityQuerier<TEntity, TIdentifierType>, the read-side surface both private helpers take (UserRegisteredHandler.cs:130, UserRegisteredHandler.cs:167); IEventBus; the Speaker aggregate; SpeakerLinkedToUser; the Email value object; Microsoft.Extensions.Logging.
  • Concept introduced, the integration-event consumer that inherits its own scope: an IIntegrationEventHandler<in TIntegrationEvent> is registered as a singleton by the framework's convention scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:194-198), which means it cannot hold a scoped IUnitOfWork as a constructor dependency: a singleton capturing a scoped EF context is the classic captive-dependency bug. This handler does not open the scope itself. It derives from ScopedIntegrationEventHandlerBase<TIntegrationEvent> (UserRegisteredHandler.cs:48), which owns the preamble: HandleAsync null-guards the event, calls scopeFactory.CreateAsyncScope(), and hands the subclass that scope's IServiceProvider inside an await using (MMCA.Common/Source/Core/MMCA.Common.Application/DomainEvents/ScopedIntegrationEventHandlerBase.cs:45-55). The subclass therefore writes only HandleScopedAsync (UserRegisteredHandler.cs:51-105) and resolves what it needs from the supplied provider (UserRegisteredHandler.cs:56-59). The class comment states the lifetime rule outright (UserRegisteredHandler.cs:36-41). [Rubric §1, SOLID]: the repeated block moved to the base and the handler kept only its own reason to change. [Rubric §7, Microservices Readiness]: Conference reacts to an Identity fact without referencing Identity's domain, only its published event contract.
  • Concept introduced, letting the delivery mechanism own retry: the base wraps the whole scoped body in an exception filter rather than a catch block that swallows: catch (Exception ex) when (ex is not OperationCanceledException && LogAndRethrow(ex, integrationEvent)) (ScopedIntegrationEventHandlerBase.cs:57). LogAndRethrow logs and returns false (ScopedIntegrationEventHandlerBase.cs:99-104), so the filter never matches, the exception keeps propagating with its original stack trace, and the throw inside the block is unreachable by construction (ScopedIntegrationEventHandlerBase.cs:59-61). A host-shutdown OperationCanceledException short-circuits the filter and passes through without an error line. This handler supplies only the message: it overrides LogHandlerFailure to write the registering user's own id through a source-generated partial (UserRegisteredHandler.cs:126-127, declared at UserRegisteredHandler.cs:212-213), replacing the base's generic template (ScopedIntegrationEventHandlerBase.cs:87-92). The remarks record the fix this replaced (UserRegisteredHandler.cs:113-124): the handler used to swallow everything, so a single transient database fault lost the auto-link permanently, because delivery had already been acknowledged. Propagating hands the decision to the transport, which is built for it: on the outbox path the message keeps its retry count and dead-letters after Outbox:MaxRetries attempts, and on the broker path MassTransit redelivers and then moves the message to the error queue (ScopedIntegrationEventHandlerBase.cs:26-34, ADR-003). Delivery is therefore at-least-once and the body must be idempotent, which is the same reasoning ADR-021 formalizes for consumers. [Rubric §29, Resilience] assesses whether failures are recoverable rather than silently absorbed; this is the difference between an alertable dead letter and a lost link nobody notices.
  • Concept introduced, an identity match must use a fact the registrant cannot forge: there is exactly ONE match strategy, and the reason the others were removed is the interesting part. TryMatchByEmailAsync (UserRegisteredHandler.cs:129-157) parses the registered address through the Email value object, bails with a warning if it is malformed (UserRegisteredHandler.cs:136-141), and queries speakers whose recorded Email equals it (UserRegisteredHandler.cs:144-148). A unique-name fallback used to run when the email missed, covering Sessionize-imported speakers whose Email is always null because the public view/All endpoint omits PII. It was deleted as a security fix (bug hunt C5): first and last name arrive straight from the attacker-controlled registration form and prove nothing, so anyone who knew a speaker's name could register under it and take over that profile (UserRegisteredHandler.cs:17-26). [Rubric §11, Security] assesses whether an authorization-relevant decision rests on a verified signal: an email is verified by the registration flow, a typed-in name is not.
  • Walkthrough:
    • HandleScopedAsync (UserRegisteredHandler.cs:51-54) receives the event and the per-delivery IServiceProvider, resolves IUnitOfWork and IEventBus from it, and asks the unit of work for the Speaker repository (UserRegisteredHandler.cs:56-59).
    • On an email miss it calls LogNameMatchCandidatesAsync and returns without linking (UserRegisteredHandler.cs:61-67). That helper is read-only by design (UserRegisteredHandler.cs:166-192): it counts unlinked speakers whose first and last name match (UserRegisteredHandler.cs:182-186) and logs the count, never returning the rows, so no code path can link on a name. The log line is the trail an organizer follows to link the speaker by hand through LinkUserToSpeakerHandler (BR-209).
    • Three idempotency guards follow, in order: a speaker already linked to a different user is left alone (UserRegisteredHandler.cs:70-74); a speaker already linked to this user re-publishes SpeakerLinkedToUser so Identity can re-sync, then returns without re-linking (UserRegisteredHandler.cs:78-84); and a rejected speaker.LinkUser(...) logs and returns (UserRegisteredHandler.cs:86-91). Together they make a redelivery a no-op, which is what licenses the base's rethrow.
    • The happy path saves (UserRegisteredHandler.cs:97), publishes SpeakerLinkedToUser through the IEventBus (UserRegisteredHandler.cs:100-102), and logs (UserRegisteredHandler.cs:104). Identity consumes that event to set User.LinkedSpeakerId.
    • One detail worth reading twice: the email query orders .OrderBy(s => s.LinkedUserId.HasValue).ThenBy(s => s.Id) before taking the first (UserRegisteredHandler.cs:153-156). When two speaker rows share an address, an arbitrary FirstOrDefault could pick an already-linked record, abandon the link, and then pick a different one on a retry; the explicit ordering makes the choice deterministic across attempts and prefers the unlinked candidate.
    • The name-count query leans on two ambient behaviors the comment spells out (UserRegisteredHandler.cs:179-181): SQL Server's case-insensitive default collation, and the global soft-delete query filter that keeps IsDeleted rows out of the count (ADR-005).
  • Why it's built this way: the auto-link is deliberately eventually consistent (UserRegisteredHandler.cs:30-35). A brand-new user's first token does not carry the speaker_id claim; it appears on the next token refresh after this handler completes. The class comment also records why the handler does not evict the 5-minute SpeakersCache output cache: doing so would require an ASP.NET Core dependency the Application layer must not take, so the cache simply expires.
  • Where it's used: registered as a singleton by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133, scanning rule at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:194-198) and fed by the Conference service host, which wires the generic IntegrationEventConsumer<T> adapter for this event with x.RegisterIntegrationEventConsumer<UserRegistered>() (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:352, explained at Program.cs:338-342). Its producer is Identity's registration path, which raises UserRegistered on the user aggregate. Covered by UserRegisteredHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Users/IntegrationEventHandlers/UserRegisteredHandlerTests.cs).
  • Caveats / not-in-source: the email query loads every matching speaker with asTracking: true (UserRegisteredHandler.cs:144-148); nothing in the file bounds that set, and nothing states a policy for what a duplicate speaker email is supposed to mean. The unique-index race described at UserRegisteredHandler.cs:93-96 (two registrations matching one speaker concurrently) is expected to surface as a DbUpdateException; that the database actually enforces a unique index on Speaker.LinkedUserId is asserted by the comment, not visible in this file.

ActivityCreateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Create · MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequest.cs:11 · Level 9 · record

  • What it is: the create-request DTO for a conference social or networking activity (a pre-conference party, a morning coffee connect, an after-party, a closing ceremony). Like SpeakerCreateRequest it doubles as the command handled by CreateActivityHandler.
  • Depends on: ICreateRequest, ICacheInvalidating, and IActivityFieldsRequest, all three declared at ActivityCreateRequest.cs:11; the Activity type for the cache prefix; the ActivityIdentifierType and EventIdentifierType module aliases.
  • Concept reinforced, an id the caller cannot choose: the contrast with SpeakerCreateRequest is the lesson. A speaker id is client-assigned because Sessionize owns it; an activity is planned inside the app, so Activity carries [IdValueGenerated] (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:19) and its factory assigns default rather than the supplied value (Activity.cs:120-125). The request still exposes an Id member, and its comment is honest about the consequence: "Database-generated; caller-provided values are ignored" (ActivityCreateRequest.cs:16). [Rubric §9, API and Contract Design] assesses whether a contract tells the caller the truth about what it does with each field.
  • Concept reinforced, the shared-field interface: implementing IActivityFieldsRequest (ActivityCreateRequest.cs:11, contract at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/IActivityFieldsRequest.cs:12-37) is what lets one generic rule set validate both the create and the update body. The interface deliberately omits EventId (IActivityFieldsRequest.cs:8-11): only the create request carries the owning event, because moving an activity between events is a create plus a delete. [Rubric §15, Best Practices & Code Quality].
  • Walkthrough: a record class with init-only members. CachePrefix => $"{typeof(Activity).FullName}:" (ActivityCreateRequest.cs:13-14) is the invalidation tag. Id (ActivityCreateRequest.cs:17) is ignored as described above. Only Name is required (ActivityCreateRequest.cs:20). Description is optional (ActivityCreateRequest.cs:23); StartTime and EndTime (ActivityCreateRequest.cs:26, ActivityCreateRequest.cs:29) are plain DateTime values in the owning event's wall-clock time. The venue trio VenueName, VenueAddress, and VenueUrl (ActivityCreateRequest.cs:32, ActivityCreateRequest.cs:35, ActivityCreateRequest.cs:38) is carried on the activity rather than inherited from the event, because an after-party is usually somewhere else; an empty VenueName means the main conference venue. SortOrder (ActivityCreateRequest.cs:41) breaks ties between activities that start at the same minute, and EventId (ActivityCreateRequest.cs:44) is the owning event.
  • Why it's built this way: keeping the venue on the activity instead of the event is a modeling decision: an activity is deliberately not a session, has no room and no speakers, and frequently happens off site. [Rubric §4, Domain-Driven Design].
  • Where it's used: bound by ActivitiesController on the ActivitiesManage-gated POST /Activities (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Activities/ActivitiesController.cs:162-171) and used as the fourth generic argument of its base controller (ActivitiesController.cs:49-50); validated by ActivityCreateRequestValidator; mapped by ActivityCreateRequestMapper; handled by CreateActivityHandler. The update side has its own request, ActivityUpdateRequest, applied by ActivityUpdateApplier.

ActivityDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.DTOs · MMCA.ADC.Conference.Application/Activities/DTOs/ActivityDTOMapper.cs:13 · Level 9 · class (sealed partial)

  • What it is: the Mapperly-generated projector from the Activity entity to ActivityDTO. It is the simplest mapper in the Conference module: no nested collections, no redaction, no injected services.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Activity, ActivityDTO, and ActivityIdentifierType (ActivityDTOMapper.cs:13-14); Riok.Mapperly's [Mapper] source generator (ActivityDTOMapper.cs:4, ActivityDTOMapper.cs:12).
  • Concept reinforced, "nothing to redact" is a decision, not an omission: compare SpeakerDTOMapper, which injects ICurrentUserService and blanks the speaker email for non-organizers (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/DTOs/SpeakerDTOMapper.cs:35-36, BR-66). This mapper takes no services at all, and the class comment says why: activity data is published to attendees by design (ActivityDTOMapper.cs:8-11). [Rubric §11, Security] assesses whether PII exposure is a considered per-field decision; the signal worth noticing here is that the absence of a filter is documented rather than accidental.
  • Walkthrough: [Mapper] on a sealed partial class (ActivityDTOMapper.cs:12-14) lets Mapperly emit the property-by-property body of the partial ActivityDTO MapToDTO(Activity entity) declaration (ActivityDTOMapper.cs:17) at compile time, so there is no reflection at runtime and a renamed or unmapped property is a build error rather than a silent null. The collection overload is hand-written: MapToDTOs null-guards and projects with a collection expression, [.. entityCollection.Select(MapToDTO)] (ActivityDTOMapper.cs:20-24). ActivityDTO carries RowVersion through IConcurrencyAware (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:15), which is what makes an optimistic-concurrency update possible from a previously read DTO (ADR-035).
  • Why it's built this way: generated mapping keeps the manual-mapping guarantee (no runtime reflection, compile-time verification) without the hand-written drudgery (ADR-001). [Rubric §15, Best Practices & Code Quality].
  • Where it's used: injected as a concrete type into CreateActivityHandler (CreateActivityHandler.cs:18); resolved as IEntityDTOMapper<...> by the EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for the module (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:91), which is what serves the read endpoints, and by the generic update handler wired through AddEntityCrud<Activity, ...> (DependencyInjection.cs:143). Registered both as itself and by its interfaces by the framework scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:200-204). Covered by ActivityDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/DTOs/ActivityDTOMapperTests.cs).
  • Caveats / not-in-source: Activity has no IEntityDTOProjector sibling (the Activities/DTOs/ folder holds only this mapper, and the projector scan at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:209-213 is opt-in), so list reads materialize entities and then map them rather than projecting server-side. Whether that is a deliberate choice for a table this small is Not determinable from source.

DeleteConferenceCategoryHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories.UseCases.Delete · MMCA.ADC.Conference.Application/Categories/UseCases/Delete/DeleteConferenceCategoryHandler.cs:14 · Level 9 · class (sealed)

  • What it is: the delete handler for a conference Category. It is a four-line subclass of the framework's generic delete workflow that changes exactly two things: the name a not-found failure reports, and the child collection the aggregate's cascade needs loaded.
  • Depends on: DeleteEntityHandler<TEntity, TIdentifierType> closed over Category and ConferenceCategoryIdentifierType (DeleteConferenceCategoryHandler.cs:14-15); IUnitOfWork, passed straight through to the base (DeleteConferenceCategoryHandler.cs:14); the CategoryItem child collection, referenced by name only.
  • Concept introduced, a generic handler with two structural extension points: [Rubric §2, Design Patterns] and [Rubric §1, SOLID]. The base is deliberately left unsealed and its workflow split into overridable steps, because the two things a real delete outgrows are structural rather than behavioral (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/DeleteEntityHandler.cs:13-32): the child collections the aggregate's own Delete() cascade has to see, declared in Includes, and a cross-aggregate invariant that must refuse the delete first, implemented in OnDeletingAsync. A subclass that overrides neither behaves exactly like the base, down to the query it issues. This handler overrides only Includes, so it keeps the base's OnDeletingAsync acceptance (DeleteEntityHandler.cs:126-130). The load-bearing consequence is in the base's LoadAsync (DeleteEntityHandler.cs:101-114): with no includes it issues the bare by-id query, and with includes it switches to the eager-loading overload under AsTracking (DeleteEntityHandler.cs:64). Declare nothing and the cascade sees an empty in-memory collection, which is how child rows survive a soft-deleted parent.
  • Concept reinforced, the aggregate owns the cascade: Category.Delete() is where BR-71 actually lives (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:102-114): it combines DeleteChildren<CategoryItem, CategoryItemIdentifierType>(_categoryItems) with base.Delete() in one Result.Combine (Category.cs:106-108), so a failing child aggregates into the combined result instead of leaving a half-applied delete, and raises CategoryChanged with DomainEntityState.Deleted only on success (Category.cs:110-111). The base handler raises no events of its own by design (DeleteEntityHandler.cs:23-26), which is what keeps the generic path and a hand-written one indistinguishable from outside. [Rubric §4, Domain-Driven Design].
  • Walkthrough:
    • The whole class is a primary constructor forwarding IUnitOfWork to the base (DeleteConferenceCategoryHandler.cs:14-15) plus two property overrides.
    • HandlerName => nameof(DeleteConferenceCategoryHandler) (DeleteConferenceCategoryHandler.cs:18) replaces the base default, which reports the open generic name DeleteEntityHandler (DeleteEntityHandler.cs:50). The value is stamped into the Source of the NotFound failure (DeleteEntityHandler.cs:74), so an API error names the module path rather than a framework type.
    • Includes => [nameof(Category.CategoryItems)] (DeleteConferenceCategoryHandler.cs:21) is the one behavioral change, naming the read-only child collection exposed at Category.cs:31.
    • Everything else runs in the base HandleAsync (DeleteEntityHandler.cs:67-89): resolve the repository from the unit of work (DeleteEntityHandler.cs:71), load (DeleteEntityHandler.cs:72), fail NotFound when the row is gone (DeleteEntityHandler.cs:73-74), run the pre-delete refusal hook (DeleteEntityHandler.cs:76-78), call entity.Delete() (DeleteEntityHandler.cs:80), and save plus log only on success (DeleteEntityHandler.cs:81-86).
  • Why it's built this way: the same shape recurs for every aggregate whose delete cascades, which is why DeleteEventHandler and DeleteSessionHandler are their own subclasses while aggregates with no owned children (Speaker, Question) are registered against the raw generic instead (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:78, DependencyInjection.cs:87). [Rubric §15, Best Practices & Code Quality]: the delete workflow exists once.
  • Where it's used: registered explicitly, not by the convention scan, as the implementation of ICommandHandler<DeleteEntityCommand<Category, ConferenceCategoryIdentifierType>, Result> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:82); invoked through the decorator pipeline by the DELETE /ConferenceCategories/{id} override of ConferenceCategoriesController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:137-146, handler injected at ConferenceCategoriesController.cs:40), a controller gated on CategoriesManage at the class level (ConferenceCategoriesController.cs:35) that evicts the conference:categories output-cache tag after the delete (ConferenceCategoriesController.cs:144).
  • Caveats / not-in-source: the explicit registration is what makes this subclass reachable. Because the command type is the closed generic DeleteEntityCommand<TEntity, TIdentifierType>, the convention scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:240-244) would also see it; nothing in this file records which registration wins, and the module uses TryAddScoped (DependencyInjection.cs:81) so ordering decides. There is no dedicated unit-test file for this handler in MMCA.ADC.Conference.Application.Tests/Categories/UseCases/.

ActivityCreateRequestMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Create · MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestMapper.cs:11 · Level 10 · class (sealed)

  • What it is: the adapter that turns a validated ActivityCreateRequest into an Activity entity by calling the aggregate's static factory. Structurally identical to SpeakerCreateRequestMapper.
  • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Activity, ActivityCreateRequest, and ActivityIdentifierType (ActivityCreateRequestMapper.cs:11-12); Activity; Result.
  • Concept reinforced, the factory decides, the mapper only forwards: CreateEntityAsync never constructs an entity itself. Activity.Create (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:99-130) combines five invariants in one Result.Combine (name, time range, venue name, venue address, venue URL: Activity.cs:111-116), returns their aggregated errors on failure (Activity.cs:117-118), assigns the id (Activity.cs:120-125), and raises ActivityChanged with DomainEntityState.Added (Activity.cs:127). [Rubric §4, Domain-Driven Design]: an Activity that exists is an Activity that passed its invariants.
  • Walkthrough: CreateEntityAsync (ActivityCreateRequestMapper.cs:15) null-guards the request (ActivityCreateRequestMapper.cs:17) and returns Task.FromResult(Activity.Create(...)) with all ten arguments taken straight from the request (ActivityCreateRequestMapper.cs:19-29). Unlike the speaker mapper, nothing is dropped here: the request and the factory have the same shape. The method is synchronous work behind an async signature, and Task.FromResult satisfies the interface without an allocation-heavy state machine.
  • Why it's built this way: one generic contract (IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>) lets the same create workflow serve every aggregate, while each aggregate keeps its own construction rules (ADR-001). [Rubric §1, SOLID]: the mapper's single responsibility is translation.
  • Where it's used: injected into CreateActivityHandler by its interface (CreateActivityHandler.cs:17) and invoked by the base create workflow (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:90); registered by the framework's request-mapper scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:215-219).
  • Caveats / not-in-source: the request's Id is forwarded (ActivityCreateRequestMapper.cs:20) but discarded downstream, because Activity is [IdValueGenerated] (Activity.cs:19) and the factory assigns default in that case (Activity.cs:120-125). If that attribute were ever removed, the same line would evaluate id!.Value on a value the request cannot leave null, and the fallback branch would never run. Nothing here guards against that.

ActivityCreateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Create · MMCA.ADC.Conference.Application/Activities/UseCases/Create/ActivityCreateRequestValidator.cs:7 · Level 10 · class (sealed)

  • What it is: the FluentValidation validator for ActivityCreateRequest. It writes no rules of its own: it includes the shared activity field rules and adds exactly one create-only delta.
  • Depends on: FluentValidation's AbstractValidator<T> (ActivityCreateRequestValidator.cs:1, ActivityCreateRequestValidator.cs:7); ActivityFieldRules<T> and ActivityEventIdRules<T> from MMCA.ADC.Conference.Application.Activities.Validation (ActivityCreateRequestValidator.cs:2).
  • Concept reinforced, two layers of rule composition: the outer layer is this class, two Include calls. The inner layer is ActivityFieldRules<T>, itself a validator constrained to where T : IActivityFieldsRequest that includes seven per-field rule sets in one place (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Activities/Validation/ActivityValidationRules.cs:129-143). Five of those extend a framework base (RequiredStringRules<T> for the name at ActivityValidationRules.cs:13-18, OptionalStringRules<T> for description, venue name, and venue address at ActivityValidationRules.cs:25-30, ActivityValidationRules.cs:37-42, ActivityValidationRules.cs:49-54), each supplying a display label and a max length that comes from ActivityInvariants, which in turn re-declares the constants owned by ActivityDTO (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:16-28, constants at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:18-30). One constant, three enforcement points (validator, domain invariant, Blazor input cap), no drift. The other two are hand-rolled: ActivityVenueUrlRules<T> wraps AbsoluteUrlRules<T> in a When so an empty value passes but a non-empty one must be absolute http or https, which is what stops a javascript: link reaching the public activity page (ActivityValidationRules.cs:63-73), and ActivityTimeRangeRules<T> is the multi-field one, compiling the start-time selector so the end-time rule can compare against it: .Must((instance, endTime) => endTime >= startTimeFunc(instance)) (ActivityValidationRules.cs:105-108). Every rule carries an explicit WithErrorCode, so a client can branch on Activity.EndTime.BeforeStart instead of matching English prose. [Rubric §24, Forms, Validation and UX Safety], [Rubric §26, Front-End Security], [Rubric §9, API and Contract Design].
  • Walkthrough: the whole class is a three-statement constructor (ActivityCreateRequestValidator.cs:9-16). Include(new ActivityFieldRules<ActivityCreateRequest>()) (ActivityCreateRequestValidator.cs:11) pulls in name, time range, sort order, description, and the three venue fields at once, with no selectors, because the rule set reads them off IActivityFieldsRequest. Include(new ActivityEventIdRules<ActivityCreateRequest>(p => p.EventId)) (ActivityCreateRequestValidator.cs:15) is the create-only delta, and the comment above it states the rule it encodes (ActivityCreateRequestValidator.cs:13-14): the owning event is chosen once, at creation, so moving an activity between events is a create plus a delete. That rule is a RequiredIdRules<T, TId> with the error code Activity.EventId.Required (ActivityValidationRules.cs:80-85).
  • Why it's built this way: hoisting the shared fields into an interface-constrained rule set means the create and update validators cannot drift apart on a field they both accept, while each keeps its own delta (ActivityUpdateRequestValidator is the other half of the pair). [Rubric §15, Best Practices & Code Quality].
  • Where it's used: discovered by AddValidatorsFromAssembly inside the module scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:252) and executed by ValidatingCommandDecorator<TCommand, TResult> before CreateActivityHandler ever runs (ADR-014). Covered by ActivityCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/Validation/ActivityCreateRequestValidatorTests.cs).
  • Caveats / not-in-source: the validator and the factory do not check the same set. ActivityDescriptionRules<T>, ActivitySortOrderRules<T>, and ActivityEventIdRules<T> have no counterpart in Activity.Create, which combines only name, time range, and the three venue fields (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:111-116). Anything that builds an Activity without going through this validator (a seeder, a future importer) can therefore produce a negative SortOrder, an over-long description, or an activity with an empty EventId. Whether that gap is intentional is Not determinable from source.

CreateActivityHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities.UseCases.Create · MMCA.ADC.Conference.Application/Activities/UseCases/Create/CreateActivityHandler.cs:15 · Level 10 · class (sealed partial)

  • What it is: the handler that creates an activity. Its entire body is one override: the log line. Everything else (map, add, save, project) comes from the framework's shared create workflow.
  • Depends on: CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> closed over ActivityCreateRequest, Activity, ActivityIdentifierType, and ActivityDTO (CreateActivityHandler.cs:20-21); IUnitOfWork, IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (satisfied by ActivityCreateRequestMapper), and ActivityDTOMapper as a concrete type, all three forwarded to the base (CreateActivityHandler.cs:16-21); Microsoft.Extensions.Logging.
  • Concept introduced, the base that stays the registered handler: [Rubric §2, Design Patterns]. CreateEntityHandlerBase<...> is an abstract class that itself implements ICommandHandler<TCreateRequest, Result<TEntityDTO>> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:42-50). That choice is deliberate and documented (CreateEntityHandlerBase.cs:17-23): the concrete app subclass remains the type the module scan discovers and the decorator pipeline wraps, and Scrutor never registers the abstract base. The workflow lives in CreateCoreAsync (CreateEntityHandlerBase.cs:77-103): optional PrepareAsync (CreateEntityHandlerBase.cs:84, default pass-through at CreateEntityHandlerBase.cs:114-118), the request mapper (CreateEntityHandlerBase.cs:90) whose errors return verbatim (CreateEntityHandlerBase.cs:91-92), repository resolution from the unit of work (CreateEntityHandlerBase.cs:95), PersistAsync (CreateEntityHandlerBase.cs:97, default AddAsync plus SaveChangesAsync at CreateEntityHandlerBase.cs:138-139), the LogCreated hook (CreateEntityHandlerBase.cs:99), the OnCreatedAsync post-commit hook (CreateEntityHandlerBase.cs:100), and finally the DTO projection (CreateEntityHandlerBase.cs:102). Resolving the repository through IUnitOfWork rather than constructor-injecting IRepository<TEntity, TIdentifierType> is a framework rule, stated in the base's own remarks (CreateEntityHandlerBase.cs:24-28): only the unit of work knows which physical data source the entity resolves to.
  • Concept reinforced, the generic create slice end to end: [Rubric §5, Vertical Slice]: request, mapper, validator, and handler live in one folder and the request IS the command. [Rubric §3, Clean Architecture]: the handler names a request mapper, a repository abstraction, and a DTO mapper and nothing from ASP.NET Core.
  • Walkthrough:
    • Primary constructor (CreateActivityHandler.cs:15-19): unit of work, the request mapper resolved by its generic interface, the DTO mapper as a concrete type, and ILogger<CreateActivityHandler>. The first three go straight to the base constructor (CreateActivityHandler.cs:20-21); only the logger is kept, and it is captured by the primary-constructor parameter rather than a field.
    • LogCreated (CreateActivityHandler.cs:24) is the one override, forwarding the persisted entity's id and name to the source-generated partial LogActivityCreated (CreateActivityHandler.cs:26-27). The base's default is an explicit no-op, because logging vocabulary is per module (CreateEntityHandlerBase.cs:148-151). Reading entity.Id here is what makes the database-generated key observable, since the hook runs after the save.
    • The class declares no HandleAsync. The base's virtual one (CreateEntityHandlerBase.cs:56-63) null-guards the command and delegates to CreateCoreAsync against the injected unit of work.
  • Why it's built this way: the pipeline supplies everything this handler does not: ValidatingCommandDecorator<TCommand, TResult> runs ActivityCreateRequestValidator first, and CachingCommandDecorator<TCommand, TResult> acts on the CachePrefix the request declares (ADR-014, chain at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:131-137). [Rubric §12, Performance & Scalability].
  • Where it's used: registered by the command-handler scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:240-244, driven from MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into ActivitiesController as ICommandHandler<ActivityCreateRequest, Result<ActivityDTO>> (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Activities/ActivitiesController.cs:42) and handed to its base controller (ActivitiesController.cs:49-50), whose POST /Activities the controller overrides only to evict the conference:activities and conference output-cache tags after the create (ActivitiesController.cs:162-171). The action is gated on the ActivitiesManage permission (ActivitiesController.cs:163) while the read endpoints stay anonymous (ActivitiesController.cs:82, ActivitiesController.cs:98). Covered by CreateActivityHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/UseCases/CreateActivityHandlerTests.cs).

UnlinkUserFromSpeakerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.UnlinkUser · MMCA.ADC.Conference.Application/Speakers/UseCases/UnlinkUser/UnlinkUserFromSpeakerHandler.cs:25 · Level 10 · class (sealed partial)

  • What it is: the handler for UnlinkUserFromSpeakerCommand. It clears the Conference side of the User-Speaker link and raises the event that clears the Identity side, and it does both inside the framework's shared load-mutate-save workflow.
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> closed over the command, Speaker, and SpeakerIdentifierType (UnlinkUserFromSpeakerHandler.cs:28); IUnitOfWork, forwarded to the base (UnlinkUserFromSpeakerHandler.cs:26); the Speaker aggregate; SpeakerUnlinkedFromUser; Result; Microsoft.Extensions.Logging. Note what is not injected: no publisher service, because the event is raised on the aggregate.
  • Concept introduced, the verb-style write over an existing aggregate: [Rubric §2, Design Patterns]. MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> is the thin shape that answers with a bare Result for publish/close/remove-style commands; it inherits the machinery from MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> and only maps the mutated aggregate to success (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:320-332). The core keeps two handler interfaces off one type on purpose, so the module scan cannot register a bogus second entry (MutateEntityHandlerBase.cs:18-28). Its workflow, MutateCoreAsync (MutateEntityHandlerBase.cs:271-309), is worth reading once: resolve the repository from the unit of work (MutateEntityHandlerBase.cs:280), load (MutateEntityHandlerBase.cs:281), fail NotFound stamped with HandlerName and the entity type (MutateEntityHandlerBase.cs:282-283), stamp the caller's RowVersion as the original when the command reports one (MutateEntityHandlerBase.cs:291-292, ADR-035), run MutateAsync and short-circuit on failure (MutateEntityHandlerBase.cs:294-296), honor the idempotent no-op flag (MutateEntityHandlerBase.cs:300-301), save (MutateEntityHandlerBase.cs:303), then log and run the post-save hook (MutateEntityHandlerBase.cs:305-306). Loading is tracked by default (MutateEntityHandlerBase.cs:76), because a no-tracking load would turn every mutation into a silent no-op.
  • Concept reinforced, raise the integration event before the save, not after: the class comment is unusually explicit about the bug this ordering removes (UnlinkUserFromSpeakerHandler.cs:12-17). Raising SpeakerUnlinkedFromUser on the aggregate inside MutateAsync (UnlinkUserFromSpeakerHandler.cs:49) means it is already on the entity when the workflow saves (MutateEntityHandlerBase.cs:303), so the outbox row and the unlink share one transaction and a crash can no longer commit the Conference-side unlink while losing the event that clears User.LinkedSpeakerId. The OutboxProcessor then routes the row to the registered IMessageBus transport (ADR-003). The remarks say the same in workflow terms (UnlinkUserFromSpeakerHandler.cs:19-24): the previously linked user id is read inside the mutation step, before the aggregate clears it, and the event is raised there too, which is what keeps it in one transaction with the unlink. [Rubric §6, CQRS and Event-Driven], [Rubric §29, Resilience].
  • Walkthrough:
    • EntityId (UnlinkUserFromSpeakerHandler.cs:31) is the one abstract member the core requires (MutateEntityHandlerBase.cs:81): it returns command.SpeakerId, which is all the workflow needs to load the aggregate.
    • Includes is left at the base default of empty (MutateEntityHandlerBase.cs:70), because the link is a scalar column on the aggregate root and nothing nested is read.
    • MutateAsync (UnlinkUserFromSpeakerHandler.cs:34-53) null-guards both arguments (UnlinkUserFromSpeakerHandler.cs:39-40), then captures previousUserId BEFORE calling entity.UnlinkUser() (UnlinkUserFromSpeakerHandler.cs:42-43). This is the load-bearing line: the domain method clears LinkedUserId (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:301), so reading it afterwards would yield null and the event could not name the user that was unlinked. The aggregate itself rejects an unlinked speaker with Speaker.NotLinked (Speaker.cs:292-299).
    • On success, and only when a previous user actually existed, it raises the event on the aggregate (UnlinkUserFromSpeakerHandler.cs:44-50) and returns the domain Result unchanged (UnlinkUserFromSpeakerHandler.cs:52), so a domain rejection reaches the API with its own codes intact.
    • LogMutated (UnlinkUserFromSpeakerHandler.cs:56-57) is the post-save log hook, calling the source-generated [LoggerMessage] partial declared at UnlinkUserFromSpeakerHandler.cs:59-60. [Rubric §13, Observability and Operability].
  • Why it's built this way: Conference and Identity own separate databases (ADR-006), so there is no foreign key to cascade and the back-link has to travel as an event; the ITransactional marker on the command plus the in-mutation raise are together what make that event as durable as the write it describes.
  • Where it's used: registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); invoked by DELETE /Speakers/{id}/link on SpeakersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:393-409, handler injected at SpeakersController.cs:53), which evicts the speaker output-cache tags and answers 204 No Content (SpeakersController.cs:407-408). The emitted event is consumed on the Identity side to clear User.LinkedSpeakerId. Covered by UnlinkUserFromSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/UnlinkUserFromSpeakerHandlerTests.cs).
  • Caveats / not-in-source: the previousUserId.HasValue test (UnlinkUserFromSpeakerHandler.cs:44) can never be false on the success path, because UnlinkUser() fails when nothing is linked (Speaker.cs:292-299). It is defensive, not a live branch. The handler also does not override RowVersion (MutateEntityHandlerBase.cs:91), so the unlink is unconditional: it carries no If-Match precondition, unlike the activity update path.

RemoveSpeakerCategoryItemHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers.UseCases.RemoveSpeakerCategoryItem · MMCA.ADC.Conference.Application/Speakers/UseCases/RemoveSpeakerCategoryItem/RemoveSpeakerCategoryItemHandler.cs:13 · Level 11 · class (sealed partial)

  • What it is: the handler for RemoveSpeakerCategoryItemCommand. It is the smallest write handler in this chapter: four members, none of them longer than a line, over the framework's remove-a-child workflow.
  • Depends on: RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType> closed over the command, Speaker, and SpeakerIdentifierType (RemoveSpeakerCategoryItemHandler.cs:16); IUnitOfWork, forwarded to the base (RemoveSpeakerCategoryItemHandler.cs:14); the Speaker aggregate and its SpeakerCategoryItem child; Result; Microsoft.Extensions.Logging.
  • Concept introduced, a base class that makes one override mandatory: [Rubric §1, SOLID] and [Rubric §15, Best Practices]. RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType> adds nothing to MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> except one line: protected abstract override IEnumerable<string> Includes { get; } (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:143-153). Re-declaring an inherited virtual as abstract turns an easy-to-forget override into a compile error. The reason is stated directly above it (ChildEntityHandlerBase.cs:130-133): a remove that cannot see the child collection cannot find the child and reports a wrong NotFound. This is a type-level fix for a class of bug that used to be a convention.
  • Concept reinforced, mutate only through the aggregate root: the handler never touches the join entity. The workflow loads the speaker with its SpeakerCategoryItems collection and MutateAsync hands the id to entity.RemoveSpeakerCategoryItem(...) (RemoveSpeakerCategoryItemHandler.cs:29), which resolves the child through the shared RemoveChildOrNotFound helper, soft-deletes it, and raises SpeakerCategoryItemChanged (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:374-385). [Rubric §4, Domain-Driven Design] assesses whether the aggregate boundary is respected on writes: a handler that deleted the join row through its own repository would bypass the domain event and the aggregate's own not-found error.
  • Walkthrough:
    • Primary constructor (RemoveSpeakerCategoryItemHandler.cs:13-16): IUnitOfWork straight to the base, plus a typed ILogger<RemoveSpeakerCategoryItemHandler> the class keeps for its own log line. No DTO mapper, because a remove returns the non-generic Result.
    • Includes => [nameof(Speaker.SpeakerCategoryItems)] (RemoveSpeakerCategoryItemHandler.cs:19) is the mandatory override, naming the collection exposed at Speaker.cs:67. It reaches the load through the core's default LoadAsync (MutateEntityHandlerBase.cs:152-160), which passes it plus AsTracking to the repository's eager-loading by-id overload.
    • EntityId (RemoveSpeakerCategoryItemHandler.cs:22) returns command.SpeakerId: the command's other id, the SpeakerCategoryItemId, addresses the child and is consumed only by the domain method.
    • MutateAsync (RemoveSpeakerCategoryItemHandler.cs:25-29) is a single expression, Task.FromResult(entity.RemoveSpeakerCategoryItem(command.SpeakerCategoryItemId)), which is exactly the shape the base's remarks prescribe (ChildEntityHandlerBase.cs:136-138).
    • LogMutated (RemoveSpeakerCategoryItemHandler.cs:32-33) forwards both ids to the source-generated [LoggerMessage] partial LogCategoryItemRemoved (RemoveSpeakerCategoryItemHandler.cs:35-36), the allocation-free, compile-checked logging idiom used by every handler in this module. It runs only after a successful save, never on the skipped-save path (MutateEntityHandlerBase.cs:179-183). [Rubric §13, Observability and Operability].
  • Why it's built this way: leaving removal semantics in the aggregate, cache eviction on the command (ICacheInvalidating), and load-mutate-save in the base leaves the handler with nothing but the four facts that are genuinely its own: which collection to load, which id to load, which domain method to call, and what to log. [Rubric §15, Best Practices & Code Quality].
  • Where it's used: registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133, scanning rule at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:240-244); invoked through the decorator pipeline by SpeakerCategoryItemsController's delete action (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:186-203), which then evicts both parents' output-cache tags (SpeakerCategoryItemsController.cs:201). Covered by RemoveSpeakerCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/RemoveSpeakerCategoryItemHandlerTests.cs).

AddSessionCategoryItemCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommand.cs:10 · Level 9 · record

  • What it is: the command that tags a session with a category item (the mechanism behind session topics, levels, and localities). Three positional parameters: the owning SessionId, an optional SessionCategoryItemId for the join entity, and the CategoryItemId being associated (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommand.cs:10-13).
  • Depends on: ICacheInvalidating, the one interface it implements (AddSessionCategoryItemCommand.cs:13); the Session type, used only for its FullName when building the cache prefix (AddSessionCategoryItemCommand.cs:1, :16); and the SessionIdentifierType, SessionCategoryItemIdentifierType, and CategoryItemIdentifierType module aliases, all three int (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15, :14, :6).
  • Concept introduced, the nullable child id on an Add command: the second parameter is SessionCategoryItemIdentifierType?, documented in the file as "Explicit ID for the join entity, or null for database-generated identity" (AddSessionCategoryItemCommand.cs:8). The REST path always passes null and lets the database assign the key (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionCategoryItemsController.cs:170); the parameter exists because that is the exact signature the aggregate factory takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:397-399), and a caller that already knows the id (the Sessionize import is the obvious one) can supply it. [Rubric §9, API and Contract Design] assesses whether a contract states exactly what a caller may decide: a nullable id rather than a defaulted one keeps "let the database choose" distinct from "I chose zero".
  • Walkthrough: the record body is one member, CachePrefix => $"{typeof(Session).FullName}:" (AddSessionCategoryItemCommand.cs:15-16). That is the same session-wide prefix AddSessionQuestionAnswerCommand and RemoveSessionCategoryItemCommand declare, so one eviction after a successful command covers every cached session projection rather than requiring per-query bookkeeping (ADR-026). The command itself never touches a cache: it only declares what it invalidates, and the caching decorator does the work (ADR-014). [Rubric §12, Performance & Scalability].
  • Where it's used: constructed by the POST /SessionCategoryItems action of SessionCategoryItemsController from an AddSessionCategoryItemRequest body (SessionCategoryItemsController.cs:165-171), on a controller gated by the SessionsManage permission (SessionCategoryItemsController.cs:47, ADR-020); validated by AddSessionCategoryItemCommandValidator; handled by AddSessionCategoryItemHandler.

PublicSessionStatusSpecification

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.Specifications · MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:20 · Level 9 · class (sealed)

  • What it is: the single definition of which session statuses an anonymous or non-privileged caller may see (BR-49): Accepted, or no status at all, since organizer-created sessions never carry one. It is an eight-line class that every public session read path in the module goes through, ANDed with the published-event scoping of BR-108 (MMCA.ADC.Conference.Application/Sessions/Specifications/PublicSessionStatusSpecification.cs:7-11).
  • Depends on: Specification<TEntity, TIdentifierType> closed over Session and SessionIdentifierType (PublicSessionStatusSpecification.cs:20); SessionStatuses for the Accepted constant (:24); and System.Linq.Expressions (:1).
  • Concept introduced, one predicate exposed in two forms: the allow-list is a public static readonly Expression<Func<Session, bool>> StatusCriteria (PublicSessionStatusSpecification.cs:23-24), and the instance Criteria override simply returns it (:27). That duality is the whole design. Call sites that need to compose the predicate into a larger expression tree take the static field, and call sites that want specification algebra (AND, OR, paging, sorting) instantiate the class; both share one definition so the rule cannot drift between them (the file says so at :13-15). [Rubric §11, Security] assesses whether a visibility rule is centralized: a status that becomes public here becomes public everywhere at once, which is what you want and also exactly why this file deserves care.
  • Concept introduced, writing predicates a database can actually run: the remarks record a trap the code deliberately avoids (PublicSessionStatusSpecification.cs:15-18). The domain already has SessionStatuses.IsEligible(status), but calling it here would put compiled C# inside an expression tree, and EF Core cannot translate a method body to SQL; the predicate would either throw or silently evaluate client-side after loading every row. So the expression compares against the Accepted constant directly. The comment also notes that SQL Server's case-insensitive default collation gives the same case behavior the in-memory predicate has. [Rubric §12, Performance and Scalability]: a translatable predicate filters in the database instead of in the process. [Rubric §8, Data Architecture]: the query stays engine-agnostic enough to survive the polyglot posture of ADR-018.
  • Walkthrough: two members and no constructor. StatusCriteria (PublicSessionStatusSpecification.cs:23-24) is s => s.Status == null || s.Status == SessionStatuses.Accepted; the null branch is not an oversight but the organizer-created case. Criteria (:27) is the override the specification pipeline consumes (ADR-055).
  • Why it's built this way: BR-49 appears in at least three query shapes; stating it once as an expression is the only way those shapes cannot disagree. Keeping it in the Application layer rather than the Domain follows from it being a read filter, not an entity invariant. The invariant form lives beside it in the domain as SessionInvariants.EnsureStatusIsEligible (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:109), which is the form SessionBookmarkValidationService and SessionQuestionAnswerRules call on an already-loaded row.
  • Where it's used: PublicConferenceVisibility uses both forms, the static expression as the localPredicate of a cross-source build (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:68) and an instance in specification algebra (PublicConferenceVisibility.cs:148); GetPublicSessionFilterHandler uses the static expression for the public session list (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/GetPublicSessionFilter/GetPublicSessionFilterHandler.cs:34, with the sharing intent stated at :17).
  • Caveats / not-in-source: the collation argument in the remarks is a statement about the deployed SQL Server, not something the type can enforce. On a case-sensitive collation or a different engine, the expression and SessionStatuses.IsEligible could disagree, and nothing in the code would catch it.

SessionBookmarkValidationService

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:12 · Level 9 · class (internal sealed)

  • What it is: Conference's implementation of a contract the Engagement module consumes. Engagement owns bookmarks but not sessions, so before it stores a bookmark it asks Conference two questions through this type: may this session be bookmarked (BR-49 and BR-91), and which sessions belong to this event (MMCA.ADC.Conference.Application/Sessions/SessionBookmarkValidationService.cs:8-11).
  • Depends on: ISessionBookmarkValidationService, the cross-module interface it implements (SessionBookmarkValidationService.cs:12); IUnitOfWork, taken by primary constructor (:12); the Session aggregate and its SessionInvariants helpers; Result and Error.
  • Concept introduced, the cross-module provider behind an interface the consumer depends on: the interface lives in MMCA.ADC.Conference.Shared, the implementation here in Conference's Application layer, and Engagement's handlers depend only on the interface (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/UserSessionBookmarks/UseCases/Create/CreateBookmarkHandler.cs:18). That indirection is what makes the module extractable: in the split topology Conference is disabled inside the Engagement process, and the Contracts project swaps the registration for a gRPC adapter with one line, services.Replace(ServiceDescriptor.Scoped<ISessionBookmarkValidationService, SessionBookmarkValidationServiceGrpcAdapter>()) (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:49). Not one line of Engagement's application code changes. [Rubric §7, Microservices Readiness] assesses exactly this: whether a cross-module call is a transport decision made at the edge (ADR-007). [Rubric §3, Clean Architecture]: the dependency points at an abstraction, never at another module's domain.
  • Walkthrough: two methods, and the first is where the business rules live.
    • ValidateSessionForBookmarkAsync (SessionBookmarkValidationService.cs:15-39) loads the session untracked with no includes (:19-24), returns Error.NotFound stamped with this service as source and Session as target when it is missing (:26-30), then runs two domain invariants in order: SessionInvariants.EnsureNotServiceSession for BR-91, since a break or a lunch slot is not something an attendee bookmarks (:33, invariant at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:94), and SessionInvariants.EnsureStatusIsEligible for BR-49 (:38, invariant at SessionInvariants.cs:109). Both are static domain functions, so the rule stays in the Domain layer and this class only decides when to ask. Note the contrast with PublicSessionStatusSpecification: that one is an EF-translatable expression for filtering a query, this one is compiled code checking an already-loaded row, and the two are deliberately different expressions of the same BR-49.
    • GetSessionIdsByEventAsync (SessionBookmarkValidationService.cs:42-54) returns the id list for one event, read untracked with a where: s => s.EventId == eventId predicate and projected into a Result<IReadOnlyCollection<SessionIdentifierType>> (:46-53). Engagement uses it to scope a user's bookmark list to a single event without holding any session data of its own. Returning ids rather than session rows is the point: it crosses the module boundary with the smallest possible payload and no schema coupling, which is also what keeps the gRPC contract trivial (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/Protos/session_bookmark_validation.proto:28). [Rubric §8, Data Architecture].
  • Why it's built this way: bookmarks and sessions live in different databases (ADR-006), so Engagement cannot join to a session table to check eligibility; the only correct move is to ask the owner. Keeping the answer in a narrow interface means the question survives the process split unchanged.
  • Where it's used: registered explicitly, not by the convention scan, as the in-process implementation at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:126; consumed by Engagement's CreateBookmarkHandler (CreateBookmarkHandler.cs:18) and GetUserBookmarksHandler; exposed over the wire by SessionBookmarksGrpcService, which wraps this instance as its inner (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:23). Covered by SessionBookmarkValidationServiceTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionBookmarkValidationServiceTests.cs:13), which pins the type through the interface rather than the concrete class.
  • Caveats / not-in-source: GetSessionIdsByEventAsync loads whole session entities and projects the ids in memory (SessionBookmarkValidationService.cs:47-53) rather than projecting in the query, so the cost scales with session row size, not id count. It also applies no visibility filter: the ids of every non-deleted session in the event are returned, eligible or not, which is safe only because the caller uses them to narrow a bookmark list the user already owns.

SessionCategoryItemDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.DTOs · MMCA.ADC.Conference.Application/Sessions/DTOs/SessionCategoryItemDTOMapper.cs:12 · Level 9 · class (sealed partial)

  • What it is: the entity-to-DTO mapper for the SessionCategoryItem join entity, source-generated by Mapperly.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SessionCategoryItem / SessionCategoryItemDTO / SessionCategoryItemIdentifierType (MMCA.ADC.Conference.Application/Sessions/DTOs/SessionCategoryItemDTOMapper.cs:13), and Riok.Mapperly.Abstractions for the [Mapper] attribute (SessionCategoryItemDTOMapper.cs:4, :11).
  • Concept reinforced, compile-time mapping: the mechanism is taught on SpeakerCategoryItemDTOMapper; this is the session-side twin, the same shape over different types. [Mapper] on a partial class makes the generator emit the body of MapToDTO as plain property assignments, so an unmatched property is a build diagnostic rather than a silent null, and there is no runtime reflection or expression compilation on the read path (ADR-001). [Rubric §12, Performance and Scalability] and [Rubric §15, Best Practices and Code Quality].
  • Walkthrough: two members. MapToDTO (SessionCategoryItemDTOMapper.cs:16) is partial with no body, which is the generator's hook. MapToDTOs (:19-23) is hand-written: ArgumentNullException.ThrowIfNull then a collection-expression spread over Select(MapToDTO). Declaring the plural on the class is what makes it reachable through the concrete type, which matters because AddSessionCategoryItemHandler injects the concrete mapper, not the interface.
  • Where it's used: injected into AddSessionCategoryItemHandler (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:18); composed into SessionDTOMapper as a [UseMapper] field (SessionDTOMapper.cs:26-27); resolved by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:113). Registered by the convention scan, self and interfaces, scoped (DependencyInjection.cs:133). Covered by SessionCategoryItemDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionCategoryItemDTOMapperTests.cs:7).

SessionQuestionAnswerDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.DTOs · MMCA.ADC.Conference.Application/Sessions/DTOs/SessionQuestionAnswerDTOMapper.cs:12 · Level 9 · class (sealed partial)

  • What it is: the Mapperly mapper for SessionQuestionAnswer, the entity that stores one attendee's answer to one session question.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SessionQuestionAnswer / SessionQuestionAnswerDTO / SessionQuestionAnswerIdentifierType (MMCA.ADC.Conference.Application/Sessions/DTOs/SessionQuestionAnswerDTOMapper.cs:13), and Riok.Mapperly.Abstractions (SessionQuestionAnswerDTOMapper.cs:11).
  • Concept reinforced: none new. Identical in structure to SessionCategoryItemDTOMapper: a partial MapToDTO the generator fills in (SessionQuestionAnswerDTOMapper.cs:16) and a hand-written null-guarded MapToDTOs (:19-23).
  • Where it's used: injected into AddSessionQuestionAnswerHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:24), where both the update branch (:96) and the create branch (:116) map through it, and into BatchAddSessionQuestionAnswersHandler, which maps the whole applied set in one spread (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersHandler.cs:25, :66); composed into SessionDTOMapper (SessionDTOMapper.cs:23-24); resolved by the query service registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:116). Covered by SessionQuestionAnswerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionQuestionAnswerDTOMapperTests.cs:7).
  • Caveats / not-in-source: the DTO shape is decided entirely by the two type definitions and the generated file, which is not in the repository. Whether an answer's author is exposed to a caller is a property question on SessionQuestionAnswerDTO, not something this file controls.

SessionQuestionAnswerRules

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerRules.cs:14 · Level 9 · class (internal static)

  • What it is: the five business rules a session feedback answer has to satisfy, lifted out of the handlers into one static class so the single-answer path and the batch path enforce exactly the same contract: BR-91 (no service sessions), BR-49 (eligible status), BR-108 (published parent event), BR-128 (the question exists and targets sessions), and BR-124 (the answer matches the question's declared type) (MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerRules.cs:8-13).
  • Depends on: SessionInvariants, EventInvariants, and QuestionInvariants from the Domain layer (:1-3); the Session, Event, and Question aggregates as parameter types; Result and Error (:4). It injects nothing and touches no repository.
  • Concept introduced, a rule module that takes its data as arguments: the two methods are deliberately pure. Neither one loads the parent event or the question it judges; both take an already-read (and possibly null) entity as a parameter, and the XML docs say why: "The parent event is passed in rather than looked up here, so the batch path can read it once for the whole request" (SessionQuestionAnswerRules.cs:17-19), and the same argument for questions at :50-51. That inversion is the whole reason the class exists. A shared rule that did its own reads would make the batch handler issue one event read and one question read per answer; taking the data as an argument lets BatchAddSessionQuestionAnswersHandler read the event once and load every distinct question in one query before it loops (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/BatchAddSessionQuestionAnswers/BatchAddSessionQuestionAnswersHandler.cs:90-97). [Rubric §12, Performance and Scalability] assesses whether a batch operation avoids per-item round-trips; here the fix is a signature decision, not a cache. [Rubric §1, SOLID]: the rule depends on data, not on the way that data is fetched, so both callers can satisfy it differently. [Rubric §15, Best Practices & Code Quality]: adding a sixth rule is one edit that both surfaces inherit, and the two paths cannot drift apart.
  • Walkthrough: two internal static methods, both returning a bare Result and both taking a source string that is stamped onto any failure so the error names the calling handler.
    • EnsureSessionAcceptsFeedback(Session session, Event? parentEvent, string source) (SessionQuestionAnswerRules.cs:24-47) runs three checks in order, each short-circuiting. SessionInvariants.EnsureNotServiceSession for BR-91 (:27-31, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:94): a break or a lunch slot takes no feedback. SessionInvariants.EnsureStatusIsEligible for BR-49 (:34-38, defined at SessionInvariants.cs:109), the same allow-list PublicSessionStatusSpecification expresses for reads, here in its compiled-code form against a loaded row. Then BR-108: a null parentEvent becomes Error.NotFound targeted at Event (:41-44), and only a non-null event reaches EventInvariants.EnsureEventIsPublished (:46, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:151). Folding the missing-event case into the rule rather than the caller is what keeps both handlers from having to remember it.
    • EnsureAnswerIsValid(Question? question, string answerValue, string source) (:56-71) is BR-128 followed by BR-124. The BR-128 branch collapses two failure modes into one error, question is null || question.QuestionEntity != "Session" (:59), returning Error.Validation coded Question.NotFoundOrWrongEntity and targeted at nameof(SessionQuestionAnswer.QuestionId) (:61-65). Deliberately not distinguishing "no such question" from "a speaker question" is a small information-disclosure decision: a caller probing ids learns nothing about which questions exist. [Rubric §11, Security]. BR-124 then delegates the type match to QuestionInvariants.EnsureAnswerValueMatchesQuestionType(question.QuestionType, answerValue, source) (:69-70, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:118), so the knowledge of what a Rating or a Text answer may look like stays in the Domain layer.
  • Why it's built this way: [Rubric §3, Clean Architecture] assesses whether the Application layer orchestrates rules that the Domain owns. Every check here forwards to a domain invariant; the class adds ordering, the null-event case, and the BR-128 shape check, and nothing else. [Rubric §5, Vertical Slice] is the tension worth naming: the two feedback slices deliberately share this file rather than each restating the rules, because a divergence between "submit one answer" and "submit a page of answers" would be a live correctness bug, not a stylistic one.
  • Where it's used: AddSessionQuestionAnswerHandler calls both methods from its private validation steps, passing the event and question it just read (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:70-71, :80-81); BatchAddSessionQuestionAnswersHandler calls EnsureSessionAcceptsFeedback once for the request (BatchAddSessionQuestionAnswersHandler.cs:83-84) and EnsureAnswerIsValid once per answer against a pre-built questionsById dictionary (:98-103). Because the class is internal static, those are the only two possible callers today, and any third has to live in the same assembly.
  • Caveats / not-in-source: there is no test class dedicated to this type; it is exercised only through the two handlers' tests. The "Session" literal at :59 is a magic string with no shared constant behind it, so a question-entity rename would have to be caught by a test rather than the compiler.

SessionSpeakerDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.DTOs · MMCA.ADC.Conference.Application/Sessions/DTOs/SessionSpeakerDTOMapper.cs:12 · Level 9 · class (sealed partial)

  • What it is: the Mapperly mapper for SessionSpeaker, the join entity that assigns a speaker to a session.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over SessionSpeaker / SessionSpeakerDTO / SessionSpeakerIdentifierType (MMCA.ADC.Conference.Application/Sessions/DTOs/SessionSpeakerDTOMapper.cs:13), and Riok.Mapperly.Abstractions (SessionSpeakerDTOMapper.cs:11).
  • Concept reinforced: none new; see SessionCategoryItemDTOMapper. Same two members, same split between the generated MapToDTO (SessionSpeakerDTOMapper.cs:16) and the hand-written MapToDTOs (:19-23).
  • Where it's used: injected as the concrete type into AddSessionSpeakerHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerHandler.cs:18); composed into SessionDTOMapper (SessionDTOMapper.cs:20-21); resolved by the query service registered for this entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:110). Covered by SessionSpeakerDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionSpeakerDTOMapperTests.cs:7).
  • Caveats / not-in-source: this mapper projects the join row only. Whether a parent SessionDTO arrives carrying its SessionSpeakers at all depends on whether the read path ran SessionNavigationPopulator for that collection; nothing in this file influences it.

AddSessionCategoryItemCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommandValidator.cs:8 · Level 10 · class (sealed)

  • What it is: the FluentValidation validator for AddSessionCategoryItemCommand. It is a single rule: the CategoryItemId must not be the default (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommandValidator.cs:10-13).
  • Depends on: FluentValidation's AbstractValidator<T> and nothing else (AddSessionCategoryItemCommandValidator.cs:1, :8).
  • Concept introduced, shape checks at the pipeline's front door: the validating decorator runs every registered validator for the command type before AddSessionCategoryItemHandler sees the message and before the transaction opens (ADR-014), so a malformed command costs no database work and the handler can assume a well-formed message. The division of labor is worth naming: "is the request well formed?" lives here, "is the operation allowed?" lives in the handler and the aggregate. That is why the duplicate-tag rule is NOT in this file: detecting it needs the loaded session, so it lives in Session.AddSessionCategoryItem as the Session.CategoryItem.Duplicate invariant (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:401-408). [Rubric §24, Forms, Validation and UX Safety] assesses whether bad input is rejected before it reaches business logic; [Rubric §15, Best Practices and Code Quality]: cheap guards stay declarative.
  • Walkthrough: an expression-bodied constructor holding one RuleFor(x => x.CategoryItemId).NotEqual(default(CategoryItemIdentifierType)).WithMessage("Category item ID is required.") (AddSessionCategoryItemCommandValidator.cs:10-13). Because CategoryItemIdentifierType is int (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6), this rejects a zero id, the value an unset JSON field binds to. Writing it as default(CategoryItemIdentifierType) rather than 0 means the rule survives an alias change to a GUID without editing.
  • Why it's built this way: the convention scan auto-registers every AbstractValidator in the assembly, services.ScanModuleApplicationServices<ClassReference>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133), so dropping a validator file next to its command is the entire wiring step. [Rubric §5, Vertical Slice]: no registration line to forget in a distant file.
  • Where it's used: resolved as IValidator<AddSessionCategoryItemCommand> by the validating decorator on every dispatch of that command. Covered by AddSessionCategoryItemCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:109), one of four validator test classes sharing that file.
  • Caveats / not-in-source: SessionId is not validated here. A zero session id therefore reaches the handler and comes back as the aggregate's not-found error rather than a validation failure. Nothing in the file says whether that is deliberate.

AddSessionCategoryItemHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:16 · Level 10 · class (sealed partial)

  • What it is: the handler for AddSessionCategoryItemCommand, and the clearest example in the chapter of a handler that has been reduced to configuration. It writes no orchestration of its own: it derives from the shared AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO> and supplies five small overrides (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:16-42).
  • Depends on: the base class, closed over AddSessionCategoryItemCommand / Session / SessionIdentifierType / SessionCategoryItem / SessionCategoryItemDTO (AddSessionCategoryItemHandler.cs:20), which is what supplies the ICommandHandler implementation; IUnitOfWork, taken by primary constructor and forwarded to the base (:17, :20); SessionCategoryItemDTOMapper, injected as the concrete type (:18); Result; and Microsoft.Extensions.Logging for the [LoggerMessage] partial (:1, :41-42).
  • Concept introduced, the add-a-child template and why its include list is abstract: the base runs the whole workflow once (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:102-126): resolve the write repository (:103), load the parent by ParentId(command) with Includes and AsTracking (:107-109), fail with a stamped NotFound when it is gone (:110-111), call Apply and short-circuit on the refused invariant (:113-115), SaveChangesAsync (:117), then LogAdded, OnAddedAsync, and MapChild (:121-124). What makes this more than a code-dedup exercise is that Includes is abstract, not virtual with an empty default, and the base's own remarks state why: "Loading the child collection is what makes the aggregate's duplicate check meaningful, so naming it has to be a deliberate act rather than an inherited default" (ChildEntityHandlerBase.cs:15-19, declaration at :55). Forgetting the join collection would leave the aggregate's duplicate check running against an empty in-memory list, and a double submit would surface as a raw unique-index 409 instead of a worded business error. The concrete handler repeats that reasoning as a comment above its own override (AddSessionCategoryItemHandler.cs:22-23). [Rubric §2, Design Patterns] assesses whether repetition is factored behind a template: this is Template Method with the one dangerous decision promoted to a compile error. [Rubric §4, Domain-Driven Design]: the aggregate owns the invariant, Session.AddSessionCategoryItem guards with _sessionCategoryItems.Exists(sci => !sci.IsDeleted && sci.CategoryItemId == categoryItemId) (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:401), and the application layer's only job is to give it the state that invariant needs. [Rubric §9, API and Contract Design]: the difference between the two outcomes is a business error with a code the client can act on versus an opaque database conflict.
  • Walkthrough: a primary constructor and five members, none longer than two lines.
    • The primary constructor takes unitOfWork, the concrete dtoMapper, and a typed ILogger, and forwards only unitOfWork to the base (AddSessionCategoryItemHandler.cs:16-20). The other two are captured for the overrides.
    • Includes => [nameof(Session.SessionCategoryItems)] (:25), carrying the correctness comment described above. AsTracking is not overridden, so the base default of true applies (ChildEntityHandlerBase.cs:50); an untracked graph would make the save a silent no-op.
    • ParentId returns command.SessionId (:28), which is the only place the command's shape meets the base's generic key.
    • Apply is the single line that does business work: parent.AddSessionCategoryItem(command.SessionCategoryItemId, command.CategoryItemId) (:31-32). The aggregate rejects a duplicate with Session.CategoryItem.Duplicate (Session.cs:401-408), creates the child through its own factory (Session.cs:410), and raises SessionCategoryItemChanged (Session.cs:418).
    • MapChild is dtoMapper.MapToDTO(child) (:35). The base calls it after the save (ChildEntityHandlerBase.cs:118, :124), which is what lets the DTO carry the identity the database generated, and is the whole reason the command's join id is nullable.
    • LogAdded forwards to the source-generated LogCategoryItemAddedToSession (:38-39, declared at :41-42), one structured Information line naming the category item and the session. [Rubric §13, Observability and Operability]: emitted once, after the save, never on the failure path.
    • Note the return shape: this add handler answers with a DTO, unlike the remove handlers in this chapter which return the bare Result. The controller needs the database-assigned join id to answer 201 Created with a location header (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionCategoryItemsController.cs:179-182).
  • Why it's built this way: atomicity is the one SaveChangesAsync inside the base covering both the join row and the domain event the aggregate raised, both in the same ADC_Conference database (ADR-006, ADR-003). Pushing the load-mutate-save shape into MMCA.Common means every module's add-a-child handler gets the same tracking, the same NotFound stamping, and the same post-save ordering for free; what stays local is the include list, the aggregate call, the mapper, and the log vocabulary. [Rubric §15, Best Practices & Code Quality].
  • Where it's used: registered by the module's application scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into SessionCategoryItemsController as ICommandHandler<AddSessionCategoryItemCommand, Result<SessionCategoryItemDTO>> (SessionCategoryItemsController.cs:50) and dispatched by its POST action, which is marked [Idempotent] so a retried request with the same Idempotency-Key replays the first response instead of adding a second row (SessionCategoryItemsController.cs:163-171), and which evicts the conference:sessions, conference:categories, and conference output-cache tags before returning (:178). Covered by AddSessionCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandlerTests.cs:12).
  • Caveats / not-in-source: HandlerName is not overridden, so the NotFound failure is stamped with GetType().Name (ChildEntityHandlerBase.cs:44), which resolves to the concrete handler's name at runtime. The OnAddedAsync post-commit hook is left at its no-op default (ChildEntityHandlerBase.cs:98-99); this slice raises no integration event.

SessionCategoryItemNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionCategoryItemNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the navigation populator for the SessionCategoryItem join entity when it is read as its own entity rather than as a child of a session. It hydrates one navigation: the parent Session back-reference (MMCA.ADC.Conference.Application/Sessions/SessionCategoryItemNavigationPopulator.cs:7-9).
  • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SessionCategoryItem (SessionCategoryItemNavigationPopulator.cs:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork, passed straight through to the base (:11-13); and the Session aggregate as the FK target.
  • Concept introduced, the FK direction of a declarative populator: Group 11 teaches the populator pattern itself; what this file introduces for the session slice is the reference direction, as opposed to the collection direction SessionNavigationPopulator also uses. A FKNavigationDescriptor reads the nullable foreign key off each parent, drops the nulls and de-duplicates (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-59), batches the distinct values into one WHERE FK IN (...) query built as an expression tree (NavigationLoader.cs:71-83), groups the results into a dictionary for O(1) lookup, and assigns each parent its match (NavigationLoader.cs:85-99). The AssignAction here therefore ends in FirstOrDefault() (SessionCategoryItemNavigationPopulator.cs:20), because a reference navigation wants one row out of a list. The descriptor also declares RequiresChildren => false (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23), which is what lets a caller ask for FK references without paying for child collections: the base tests that flag against the includeFKs / includeChildren arguments before loading anything (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36-40). [Rubric §12, Performance and Scalability]: one batched query for the whole page of rows, never one per row. [Rubric §2, Design Patterns]: Template Method configured by data rather than by virtual methods.
  • Walkthrough: the class body is empty (SessionCategoryItemNavigationPopulator.cs:23-24). Everything it says is said in the base-constructor argument list: one descriptor with PropertyName = nameof(SessionCategoryItem.Session) (:17), ParentKeySelector = e => e.SessionId (:18), ChildForeignKeySelector = child => child.Id (:19), and AssignAction = (e, sessions) => e.SetSession(sessions.FirstOrDefault()) (:20). Two details are load-bearing. PropertyName is not decoration: the base loads a descriptor only when that exact property name appears in the query's UnsupportedIncludes metadata (DeclarativeNavigationPopulator.cs:27-38), so a name typo means a silently unpopulated navigation rather than a compile error. And the assignment goes through the entity's own SetSession mutator (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionCategoryItem.cs:61) rather than a public property setter, so hydration uses the same door a business operation would. [Rubric §4, Domain-Driven Design].
  • Why it's built this way: this indirection exists because of the cross-source degradation rule. When a relationship can span physical data sources, EF's navigation is stripped and only the scalar foreign key survives, so hydration has to be a second batched query rather than an Include (ADR-002, ADR-006). [Rubric §3, Clean Architecture]: the Application layer describes hydration with repository abstractions and property selectors, with no EF Core namespace anywhere in the file.
  • Where it's used: registered as the INavigationPopulator<SessionCategoryItem> implementation, services.TryAddScoped<INavigationPopulator<SessionCategoryItem>, SessionCategoryItemNavigationPopulator>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:112), directly above the base EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registration for the same entity (:110), which is the pairing that puts it on every direct read of a session category item (ADR-034). Covered by SessionCategoryItemNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionCategoryItemNavigationPopulatorTests.cs:9).

SessionDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.DTOs · MMCA.ADC.Conference.Application/Sessions/DTOs/SessionDTOMapper.cs:14 · Level 10 · class (sealed partial)

  • What it is: the mapper that turns a Session aggregate into a SessionDTO, including its three child collections. It is the composite of the three child mappers in this unit, and that is why it sits a level above them.
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Session / SessionDTO / SessionIdentifierType (MMCA.ADC.Conference.Application/Sessions/DTOs/SessionDTOMapper.cs:18); SessionSpeakerDTOMapper, SessionQuestionAnswerDTOMapper, and SessionCategoryItemDTOMapper, all three taken by primary constructor (SessionDTOMapper.cs:14-17); Riok.Mapperly.Abstractions.
  • Concept introduced, composing generated mappers with [UseMapper]: the three injected mappers are stored in fields marked [UseMapper] (SessionDTOMapper.cs:20-27). That attribute tells Mapperly: when you need to map a SessionSpeaker while generating the body of MapToDTO(Session), do not invent a nested mapping, call this field. The result is one generated method per type, reused wherever the type appears, instead of a copy of the child mapping inlined into every parent. Change how a SessionSpeaker projects and every parent DTO that embeds one follows automatically. [Rubric §1, SOLID]: each mapper has one reason to change. [Rubric §15, Best Practices & Code Quality]: the composition is declared in three fields rather than maintained as duplicated assignment code.
  • Walkthrough: three [UseMapper] readonly fields assigned from the primary-constructor parameters (SessionDTOMapper.cs:20-27), the partial MapToDTO the generator fills in (:30), and the hand-written MapToDTOs with its null guard and Select spread (:33-37). The class is sealed partial and carries [Mapper] (:13-14), which is what makes the generation happen at all.
  • Why it's built this way: generated composition keeps the DTO projection compile-checked end to end, so adding a property to SessionDTO that no entity property feeds is a build error rather than a null in a response (ADR-001).
  • Where it's used: injected as the concrete type into CreateSessionHandler, which forwards it to its CreateEntityHandlerBase (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:26, :29-30), and into UpdateSessionHandler, which maps the saved entity directly (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:21, mapped at :115); resolved as IEntityDTOMapper<Session, SessionDTO, SessionIdentifierType> by the generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered for sessions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:73), which is what puts it on every session read (ADR-034). Registered self-and-interfaces by the convention scan (DependencyInjection.cs:133). Covered by SessionDTOMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/DTOs/SessionDTOMapperTests.cs:8).
  • Caveats / not-in-source: the generated file is not in the repository, so which properties actually get copied is observable only from the two type definitions and a build. In particular, whether the child collections or the Event and Room references are populated at map time depends entirely on whether the read path ran SessionNavigationPopulator for them first; the mapper maps what it is given.

SessionNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:13 · Level 10 · class (sealed)

  • What it is: the navigation populator for the Session aggregate, and the richest one in the Conference module. It declares five navigations that EF Core cannot materialize through .Include() on this model: FK references to Event and Room, and the three child collections SessionSpeakers, SessionQuestionAnswers, and SessionCategoryItems (MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:8-11). Notably, its class body is empty.
  • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Session (SessionNavigationPopulator.cs:15); both descriptor kinds, FKNavigationDescriptor<TEntity, TChild, TChildId> (:17, :24) and ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> (:31, :38, :45); IUnitOfWork, passed straight through to the base (:13-15); and the Event, Room, SessionSpeaker, SessionQuestionAnswer, and SessionCategoryItem types.
  • Concept introduced, the two descriptor kinds side by side: this is the one file in the session slice where both appear, so it is the clearest place to see the difference. An FKNavigationDescriptor walks forward along a foreign key the parent holds and assigns one row (AssignAction ends in FirstOrDefault(), :22, :29); a ChildNavigationDescriptor walks backward from a foreign key the children hold and assigns the whole list (:36, :43, :50). The base treats them differently at load time: RequiresChildren is false on the FK kind (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23) and true on the child kind (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25), and it tests that flag against the caller's includeFKs and includeChildren arguments before loading (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36-40). A session list can therefore fetch its room and event labels without dragging every answer row along with them. [Rubric §12, Performance and Scalability] assesses whether a read pays only for what it asked for; [Rubric §2, Design Patterns]: the subclass supplies data, not overrides, which is why the body is genuinely empty (SessionNavigationPopulator.cs:53-54).
  • Walkthrough: five descriptors, each supplying the same four settings.
    • Event (SessionNavigationPopulator.cs:17-23): PropertyName = nameof(Session.Event) (:19), ParentKeySelector = e => e.EventId (:20), ChildForeignKeySelector = child => child.Id (:21), AssignAction = (e, events) => e.SetEvent(events.FirstOrDefault()) (:22).
    • Room (:24-30) repeats that shape over RoomId, which is nullable on the session because a session need not be scheduled into a room yet; LoadFKPropertyAsync drops null keys before it builds the IN clause and assigns an empty list to every parent when no key survives (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-69).
    • SessionSpeakers (:31-37), SessionQuestionAnswers (:38-44), and SessionCategoryItems (:45-51) each invert the direction: ParentKeySelector = e => e.Id, ChildForeignKeySelector = child => child.SessionId, and an AssignAction that calls the aggregate's own SetSessionSpeakers / SetSessionQuestionAnswers / SetSessionCategoryItems mutator (:36, :43, :50, declared at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:386, :546, :472).
    • Every AssignAction goes through an aggregate member rather than a back-door property write, including the two FK references, which use the public SetEvent and SetRoom (Session.cs:301, :305). The three collection mutators are internal, reachable only because the Domain project grants InternalsVisibleTo to the Application assembly (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3), which keeps them off the public surface while still letting hydration pass through the same door a business operation would. [Rubric §4, Domain-Driven Design].
  • Why it's built this way: one descriptor per navigation makes adding a relationship a data edit rather than a new query method, and every aggregate in the module hydrates through one code path (ADR-002).
  • Where it's used: registered as the INavigationPopulator<Session> implementation, services.TryAddScoped<INavigationPopulator<Session>, SessionNavigationPopulator>() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:72), immediately above the session query-service and custom-delete registrations that complete the aggregate's block (:70-71), and resolved by the navigation-population step of the generic query layer whenever a Session is read with any of these five navigations requested (ADR-034). Its output is what SessionDTOMapper projects. Covered by SessionNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionNavigationPopulatorTests.cs:9).
  • Caveats / not-in-source: the write handlers in this unit do not go through this populator at all. AddSessionCategoryItemHandler declares an Includes override the shared base passes to the repository (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:25), and AddSessionQuestionAnswerHandler passes an explicit includes array of its own (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:34-38), because both need a tracked graph and this populator's loads are untracked (NavigationLoader.cs:83). Read paths and write paths hydrate the same collections by two different mechanisms, and nothing in either file cross-references the other.

SessionQuestionAnswerNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the navigation populator for SessionQuestionAnswer read as its own entity. One navigation: the parent Session back-reference (MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerNavigationPopulator.cs:7-9).
  • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SessionQuestionAnswer (SessionQuestionAnswerNavigationPopulator.cs:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork (:11-12); the Session aggregate as the FK target.
  • Concept reinforced: none new. Structurally identical to SessionCategoryItemNavigationPopulator, which teaches the FK direction: PropertyName = nameof(SessionQuestionAnswer.Session) (:17), ParentKeySelector = e => e.SessionId (:18), ChildForeignKeySelector = child => child.Id (:19), AssignAction = (e, sessions) => e.SetSession(sessions.FirstOrDefault()) (:20, mutator at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionQuestionAnswer.cs:84), and an empty class body (:23-24).
  • Where it's used: registered at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:115, paired with the base EntityQueryService<TEntity, TEntityDTO, TIdentifierType> for the same entity (:113). Covered by SessionQuestionAnswerNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionQuestionAnswerNavigationPopulatorTests.cs:9).
  • Caveats / not-in-source: this populator hydrates the parent session on an answer row, but it applies no visibility filter of its own; the eligibility rules that gate writing an answer (SessionQuestionAnswerRules) have no counterpart here. Whether a direct answer read is scoped is decided by the query's specification, not by this file.

SessionSpeakerNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions · MMCA.ADC.Conference.Application/Sessions/SessionSpeakerNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the navigation populator for SessionSpeaker read as its own entity. One navigation: the parent Session back-reference (MMCA.ADC.Conference.Application/Sessions/SessionSpeakerNavigationPopulator.cs:7-9).
  • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SessionSpeaker (SessionSpeakerNavigationPopulator.cs:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork (:11-12); the Session aggregate as the FK target.
  • Concept reinforced: none new; see SessionCategoryItemNavigationPopulator. Same single descriptor over PropertyName = nameof(SessionSpeaker.Session) (:17), ParentKeySelector = e => e.SessionId (:18), ChildForeignKeySelector = child => child.Id (:19), AssignAction ending in SetSession(sessions.FirstOrDefault()) (:20, mutator at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionSpeaker.cs:61), and an empty class body (:23-24). Worth noticing what is absent: the descriptor list does not include the Speaker side of the join, so a directly read SessionSpeaker gets its session hydrated but not its speaker.
  • Where it's used: registered at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:109, paired with the base EntityQueryService<TEntity, TEntityDTO, TIdentifierType> for the same entity (:107). Covered by SessionSpeakerNavigationPopulatorTests, which pins the type to INavigationPopulator<SessionSpeaker> and asserts the empty-collection and empty-metadata short circuits (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/SessionSpeakerNavigationPopulatorTests.cs:9, :20-21, :23-31).
  • Caveats / not-in-source: no source comment explains why the Speaker navigation is left out of this descriptor list while Session is present. It is consistent with the module's other join populators, which all hydrate one side only, but the file itself does not say so.

AddSessionQuestionAnswerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommand.cs:11 · Level 9 · record

  • What it is: the message an attendee's session feedback travels on. Four positional fields: the owning SessionId, an optional SessionQuestionAnswerId for the answer row, the QuestionId being answered, and the AnswerValue text (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommand.cs:11-15).
  • Depends on: ICacheInvalidating (AddSessionQuestionAnswerCommand.cs:15), the Session type referenced only for its FullName when building the cache prefix, and the SessionIdentifierType / SessionQuestionAnswerIdentifierType / QuestionIdentifierType module aliases (ADR-048).
  • Concept reinforced: none new. The nullable child id works exactly as on AddSessionCategoryItemCommand; the file states the same contract at AddSessionQuestionAnswerCommand.cs:8, "Explicit ID for the answer entity, or null for database-generated identity", and the REST path always passes null (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:172). The parameter exists because that is the shape the aggregate method takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:484).
  • Walkthrough: one member, CachePrefix => $"{typeof(Session).FullName}:" (AddSessionQuestionAnswerCommand.cs:17-18). The interesting part of this record is what it does not carry: no author id. Ownership is resolved server side from ICurrentUserService inside AddSessionQuestionAnswerHandler (AddSessionQuestionAnswerHandler.cs:52), so a client cannot submit feedback as someone else. [Rubric §11, Security] assesses whether identity ever travels as request data; here it does not.
  • Where it's used: validated by AddSessionQuestionAnswerCommandValidator, handled by AddSessionQuestionAnswerHandler, and built from an AddSessionQuestionAnswerRequest by the [Idempotent] POST /SessionQuestionAnswers action of SessionQuestionAnswersController (SessionQuestionAnswersController.cs:166-173, ADR-017), on a controller whose whole surface requires an authenticated caller (SessionQuestionAnswersController.cs:75).

AddSessionSpeakerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommand.cs:10 · Level 9 · record

  • What it is: the command that attaches a speaker to an existing session. Three positional parameters: the owning SessionId, an optional SessionSpeakerId for the join row, and the SpeakerId being associated (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommand.cs:10-13).
  • Depends on: ICacheInvalidating (AddSessionSpeakerCommand.cs:13); the Session type, referenced only for its FullName when building the cache prefix; and the SessionIdentifierType / SessionSpeakerIdentifierType / SpeakerIdentifierType module aliases (ADR-048).
  • Concept reinforced, the nullable child id on an Add command: the second parameter is SessionSpeakerIdentifierType?, documented in the file as "Explicit ID for the join entity, or null for database-generated identity" (AddSessionSpeakerCommand.cs:8). The REST path always passes null and lets the database assign the key (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSpeakersController.cs:170); the parameter exists because that is the exact shape the aggregate method takes (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:315-317). The same shape is taught on AddSessionCategoryItemCommand.
  • Walkthrough: the record body is a single member, CachePrefix => $"{typeof(Session).FullName}:" (AddSessionSpeakerCommand.cs:15-16). That is the session-wide prefix every session write in this module declares, so one eviction after a successful command clears every cached session projection instead of requiring per-query bookkeeping (ADR-026). The command itself never touches a cache: it declares what it invalidates and the caching decorator does the work (ADR-014). [Rubric §12, Performance & Scalability] assesses whether concerns like caching live in one pipeline stage rather than being re-implemented per handler; here the handler has no cache code at all.
  • Where it's used: constructed by the hand-written POST /SessionSpeakers action of SessionSpeakersController from an AddSessionSpeakerRequest body (SessionSpeakersController.cs:163-171), on a controller gated by the SessionsManage permission (SessionSpeakersController.cs:47, ADR-020) and marked [Idempotent] so a retried request replays the first response rather than adding a second row (SessionSpeakersController.cs:164, ADR-017); validated by AddSessionSpeakerCommandValidator; handled by AddSessionSpeakerHandler.

DeleteSessionHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Delete · MMCA.ADC.Conference.Application/Sessions/UseCases/Delete/DeleteSessionHandler.cs:16 · Level 9 · class (sealed partial)

  • What it is: the module's Session-specific subclass of the framework's generic delete handler. It adds exactly two things to the inherited workflow: the child collections the aggregate's cascade has to see, and a log line. It has no HandleAsync of its own.
  • Depends on: DeleteEntityHandler<TEntity, TIdentifierType> closed over Session and SessionIdentifierType (DeleteSessionHandler.cs:19); IUnitOfWork, passed straight through to the base (:17, :19); DeleteEntityCommand<TEntity, TIdentifierType> as the message shape (:29); Microsoft.Extensions.Logging for the source-generated log method.
  • Concept introduced, specializing a generic handler by overriding hooks rather than rewriting it: DeleteEntityHandler<TEntity, TIdentifierType> is deliberately left unsealed, and its workflow is split into overridable steps precisely so a module can adjust the two things a real delete outgrows: which navigations to load, and a cross-aggregate refusal (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/DeleteEntityHandler.cs:13-22). A subclass that overrides neither behaves identically to the base, down to the query it issues. This class overrides Includes and LogDeleted and nothing else, so the load-guard-delete-save sequence at DeleteEntityHandler.cs:67-89 is the code that actually runs. [Rubric §1, SOLID] assesses substitutability at the abstraction: because the DI registration is keyed by the closed ICommandHandler<DeleteEntityCommand<Session, SessionIdentifierType>, Result> interface (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:74), nothing upstream knows a subclass was substituted. [Rubric §15, Best Practices & Code Quality]: the whole specialization is eighteen lines. See ADR-099, which records the extensible DeleteEntityHandler as part of the generic write-side surface.
  • Concept introduced, why a soft-delete cascade is load-order sensitive: Session.Delete() (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:283-293) combines three DeleteChildren<...> calls over _sessionSpeakers, _sessionQuestionAnswers, and _sessionCategoryItems with the base delete, children first and root last per BR-55 (Session.cs:285-289). Those are in-memory backing lists, so they contain only what EF materialized. The base handler loads no navigations by default (DeleteEntityHandler.cs:58), which would leave the join rows active under a soft-deleted session; the class summary states this trade-off directly (DeleteSessionHandler.cs:8-15). [Rubric §4, DDD] assesses whether the aggregate boundary is enforced at write time: the rule lives on the entity, and the subclass's only job is to declare what has to be hydrated before it runs. [Rubric §8, Data Architecture]: soft-delete is the workspace default (ADR-005), so an orphaned active child is a data-correctness bug, not a cosmetic one.
  • Walkthrough: three overrides and one log method, no imperative code at all.
    • HandlerName (DeleteSessionHandler.cs:22) returns nameof(DeleteSessionHandler). The base defaults to the open generic name so a failure reads the same whichever subclass produced it (DeleteEntityHandler.cs:50); this override opts back into the concrete name, so a NotFound failure keeps reporting DeleteSessionHandler as its Source exactly as it always has.
    • Includes (:25-26) returns a collection expression naming all three owned child collections through nameof. Declaring them is what switches the base's LoadAsync from the bare by-id query to the eager-loading overload under AsTracking (DeleteEntityHandler.cs:109-113). Tracking is true by default (DeleteEntityHandler.cs:64), which matters because the cascade mutates the loaded children and SaveChangesAsync must see them.
    • LogDeleted (:29-30) is the base's post-save hook, a no-op by default (DeleteEntityHandler.cs:138-141); here it forwards to the module's own message template.
    • LogSessionDeleted (:32-33) is a [LoggerMessage] source-generated partial: compile-time-checked template, strongly typed SessionIdentifierType parameter, no boxing. This is the logging shape on every handler in the module. [Rubric §13, Observability and Operability] (ADR-041).
  • Why it's built this way: teaching the generic handler to include navigations by itself is impossible, because framework code has no way to know which navigations are owned. Making that one fact a per-module override is the smallest thing that closes the gap. The Event delete path has the same shape, which is why DependencyInjection.cs:70 registers DeleteEventHandler for the same reason, while aggregates with no owned cascade keep the raw generic (DependencyInjection.cs:78 for Speaker, :84 for Question).
  • Where it's used: registered at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:74; invoked through the deleteHandler constructor parameter of SessionsController (SessionsController.cs:49), which passes it into AggregateRootEntityControllerBase (SessionsController.cs:57-58). Covered by DeleteSessionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/Delete/DeleteSessionHandlerTests.cs:12).
  • Caveats / not-in-source: the handler declares no cache prefix of its own, and it does not override OnDeletingAsync (DeleteEntityHandler.cs:126-130), so there is no pre-delete cross-aggregate refusal on this path. DeleteEntityCommand<Session, SessionIdentifierType> is the message travelling the pipeline, so whether a session delete evicts the session cache is decided by that generic command's contract, not by anything in this file.

GetNowNextQuery

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.NowNext · MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23 · Level 9 · record

  • What it is: the request for the "happening now / up next" snapshot. One parameter, EventIdentifierType? EventId: a value targets that published event, null asks the handler to feature the current-or-next published event (MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextQuery.cs:23).
  • Depends on: IQueryCacheable (GetNowNextQuery.cs:23); the Session type for the key prefix; System.Globalization for the invariant id formatting (GetNowNextQuery.cs:1).
  • Concept introduced, a query that declares its own cache key: commands implement ICacheInvalidating and name a prefix to evict; queries implement IQueryCacheable and name a key plus a TTL. The caching decorator reads both, so a cached read and the writes that invalidate it agree only because they agree on the prefix string (ADR-014, ADR-026). [Rubric §12, Performance and Scalability] assesses whether hot reads are cached at a layer that can be evicted correctly; the doc comment states the reasoning, a hot, public, non-user-specific read behind a home-screen widget (GetNowNextQuery.cs:13-20).
  • Walkthrough: two computed members and no methods.
    • CacheKey (GetNowNextQuery.cs:26-35) builds {Session full name}:NowNext:{scope} where scope is the event id formatted with CultureInfo.InvariantCulture, or the literal "current" when the id is null (:30-33). Two things matter here. The key sits under the same Session aggregate prefix the session commands declare as their CachePrefix, so any session write evicts this entry through prefix eviction once an IConnectionMultiplexer is registered. And the id-less form gets its own stable key rather than colliding with whichever event happens to be current.
    • CacheDuration (GetNowNextQuery.cs:38) is TimeSpan.FromSeconds(30). The file explains why the TTL is short and why it is not redundant with prefix eviction (:16-19): the payload changes with the wall clock as sessions roll over time buckets, event-level edits are not session writes, and the TTL is the sole backstop when prefix eviction is unavailable. [Rubric §29, Resilience and Business Continuity]: correctness degrades to "at most 30 seconds stale" rather than "wrong until someone writes a session".
  • Why it's built this way: the widget has no event id of its own (GetNowNextQuery.cs:10-12), so an id-less form has to exist; making it a nullable parameter on one query rather than a second query type keeps one handler, one cache policy, and one payload shape (ADR-042 Wave 8, cited in the file at :8).
  • Where it's used: constructed twice by EventsController, with an id for the per-event now-next action (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:178) and with EventId: null for the event-less one (EventsController.cs:192). Both actions are [AllowAnonymous] and carry [OutputCache(PolicyName = "NowNextCache")] (EventsController.cs:172-173, :186-187), a 60-second public policy tagged conference and conference:sessions (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:232). Handled by GetNowNextHandler. Covered by GetNowNextQueryCacheTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextQueryCacheTests.cs:14).
  • Caveats / not-in-source: there are two independent cache layers on this read, the 30-second query cache declared here and the 60-second HTTP output cache declared on the endpoint. Nothing in source ties the two durations together, so the staleness a widget actually sees is bounded by the output cache, not by CacheDuration.

RemoveSessionCategoryItemCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemCommand.cs:9 · Level 9 · record

  • What it is: the command that detaches a category item (a topic, level, or locality tag) from a session. Two positional ids: the owning SessionId and the SessionCategoryItemId join row to remove (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemCommand.cs:9-11).
  • Depends on: ICacheInvalidating (RemoveSessionCategoryItemCommand.cs:11), the Session type for the prefix, and the SessionIdentifierType / SessionCategoryItemIdentifierType aliases.
  • Concept reinforced: none new. Note the asymmetry with the matching Add command: AddSessionCategoryItemCommand takes a nullable child id (the database may assign it), while every Remove command takes a required one. There is nothing to generate on removal, so a null there would only be a way to express "remove nothing".
  • Walkthrough: one member, CachePrefix => $"{typeof(Session).FullName}:" (RemoveSessionCategoryItemCommand.cs:13-14), identical to the Add side, so adding and removing a tag evict the same set of cached session projections.
  • Where it's used: constructed by the DELETE /SessionCategoryItems/{id} action of SessionCategoryItemsController, which takes the join id from the route and the session id from an optional query string (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionCategoryItemsController.cs:186-194), on a controller gated by the SessionsManage permission (SessionCategoryItemsController.cs:47); handled by RemoveSessionCategoryItemHandler.
  • Caveats / not-in-source: because SessionId is a value-typed alias with no "absent" representation, a caller that omits the optional query parameter model-binds it to 0 rather than being rejected. That is the exact shape RemoveSessionCategoryItemHandler has to cope with, and nothing on this record says so.

SessionCreateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Create · MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequest.cs:11 · Level 9 · record class

  • What it is: the POST body for creating a session and, unusually, the command message itself. There is no separate CreateSessionCommand: CreateSessionHandler closes CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> over this record (MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:29-30), so the request record travels the whole decorator pipeline unchanged.
  • Depends on: ICreateRequest, ICacheInvalidating, and ISessionFieldsRequest (SessionCreateRequest.cs:11); the Session type for the prefix; the SessionIdentifierType, EventIdentifierType, and RoomIdentifierType aliases.
  • Concept introduced, the request DTO as the command: ICreateRequest is a pure marker with no members; its only job is to be a generic constraint on IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> and on the create handler base (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:47). That constraint is what lets the generic create pipeline in AggregateRootEntityControllerBase bind a request body straight to a handler with no intermediate command type (SessionsController.cs:57-58). [Rubric §9, API and Contract Design] assesses whether the wire contract is explicit: it is, but the price is that the HTTP contract and the internal command contract are one type and cannot evolve independently. [Rubric §34, Architecture Governance & Documentation]: one type instead of two, at the cost of that coupling.
  • Concept introduced, an interface that exists to make a validator reusable: ISessionFieldsRequest declares the seven session fields the create and update requests validate identically, Title, Description, Status, LiveUrl, RecordingUrl, AccessibilityInfo, and ResourceLinks (MMCA.ADC.Conference.Application/Sessions/Validation/ISessionFieldsRequest.cs:13-35). Implementing it is what lets SessionFieldRules<T> declare the shared rule list once over a constrained T (SessionValidationRules.cs:111-124). Fields validated by only one operation stay off the interface deliberately: EventId is create-only, because BR-140 makes it immutable afterwards (ISessionFieldsRequest.cs:8-12). [Rubric §1, SOLID]: the interface is the smallest surface that makes the rules generic, not a mirror of the record.
  • Walkthrough: CachePrefix (SessionCreateRequest.cs:14) is the same session-wide prefix the child commands use, so a create evicts cached session reads. Seventeen init-only data properties follow. Only two are required: Title (:20) and EventId (:62). Id (:17) is a plain non-nullable SessionIdentifierType documented as "auto-generated if not provided", which in practice means a caller sends nothing and the property arrives as 0; CreateSessionHandler reads that 0 as its signal to allocate an id from the reserved manual range. LiveUrl, RecordingUrl, AccessibilityInfo, and ResourceLinks (:47, :50, :53, :56) are nullable strings rather than Uri, matching how Sessionize exports them; the validation rule sets say the same thing, length-only because the value is stored as an opaque string for Sessionize compatibility (SessionValidationRules.cs:56-58). Status (:32) is a nullable string, not an enum, which is what makes a session with no status at all representable (the organizer-created case PublicSessionStatusSpecification has to allow for). [Rubric §15, Best Practices and Code Quality]: init-only accessors make the request immutable once bound, and CreateSessionHandler uses with rather than mutation when it fills in the id (CreateSessionHandler.cs:94).
  • Where it's used: bound from the body of POST /Sessions on SessionsController (SessionsController.cs:281-283), an action that overrides the base create purely to add the BR-86 date-range warning header and to make the [Idempotent] contract visible at the ADC endpoint (SessionsController.cs:274-280); validated by SessionCreateRequestValidator; turned into an entity by SessionCreateRequestMapper; handled by CreateSessionHandler.
  • Caveats / not-in-source: Duration (:59) never reaches the domain. It is not among the arguments SessionCreateRequestMapper passes to Session.Create (SessionCreateRequestMapper.cs:19-35), and Session.Create has no parameter for it (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:165-181). That is by design, since the entity computes it from StartsAt and EndsAt (Session.cs:80), but a caller can send a value that is silently discarded and nothing in the contract says so.

AddSessionQuestionAnswerCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommandValidator.cs:8 · Level 10 · class (sealed)

  • What it is: the FluentValidation validator for AddSessionQuestionAnswerCommand. One rule: RuleFor(x => x.AnswerValue).NotEmpty() with the message "Answer value is required." (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommandValidator.cs:10-13).
  • Depends on: FluentValidation's AbstractValidator<T> and nothing else (AddSessionQuestionAnswerCommandValidator.cs:1, :8).
  • Concept reinforced: the validating decorator stage, taught on AddSessionCategoryItemCommandValidator. The handler never calls this class; registration is by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133).
  • Why it's built this way: NotEmpty covers null, empty, and whitespace-only text, which is all that can be judged without knowing the question. The semantic check, that the answer matches the question's declared type, needs the Question row and therefore lives behind the handler as BR-124, in SessionQuestionAnswerRules.EnsureAnswerIsValid (MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerRules.cs:69-70). Shape rules here, data-dependent rules where the data is. [Rubric §24, Forms, Validation and UX Safety].
  • Where it's used: resolved by the validating decorator for AddSessionQuestionAnswerCommand; covered by AddSessionQuestionAnswerCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:9).
  • Caveats / not-in-source: neither SessionId nor QuestionId is validated here, so a zero id passes validation and fails later, the session id as a NotFound from the handler's load and the question id as the BR-128 validation failure.

AddSessionQuestionAnswerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:21 · Level 10 · class (sealed partial)

  • What it is: the richest write path among the session child commands, and the one that does not use a shared handler base. Where its siblings subclass a framework workflow, this one is hand-written, because it runs a chain of business rules before deciding between creating a new answer and updating the caller's existing one, and raises a cross-module integration event on the create branch only (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandler.cs:15-20 names the rules: BR-91, BR-49, BR-108, BR-128, BR-124, BR-107).
  • Depends on: ICommandHandler<in TCommand, TResult> closed over the command and Result<SessionQuestionAnswerDTO> (AddSessionQuestionAnswerHandler.cs:26); IUnitOfWork (:21); ICurrentUserService (:22); the concrete SessionQuestionAnswerDTOMapper (:23); the BCL TimeProvider (:24); SessionQuestionAnswerRules for the rule chain (:69, :80); the Session, Event, and Question aggregates; SessionFeedbackSubmitted; Result / Error.
  • Concept introduced, an application-level upsert over an aggregate, and where its race is caught: [Rubric §6, CQRS and Event-Driven] assesses whether a command slice owns its full decision, and [Rubric §8, Data Architecture] assesses whether an integrity rule has a database-level guarantee and not only an in-memory one. BR-107 says one live answer per (session, question, author), so the handler looks for an existing non-deleted answer by the current user for this question in the already loaded child collection (:53-54) and branches: found means update, not found means create (:56-59). That check is in-memory by construction, so two concurrent submissions can both take the create branch. The database is the backstop: a unique index on (SessionId, QuestionId, CreatedBy) stops the second write (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionQuestionAnswerConfiguration.cs:43-44). Reading the handler alone would leave you thinking the rule is best-effort; reading the pair shows the real guarantee.
  • Concept introduced, an integration event raised on the aggregate pre-save so the outbox captures it atomically: [Rubric §7, Microservices Readiness] assesses whether modules collaborate without reaching into each other's data. On the create branch only, the handler calls session.AddDomainEvent(new SessionFeedbackSubmitted(userId, session.Id, session.EventId, timeProvider.GetUtcNow().UtcDateTime)) (:112) before the save, so the event row and the answer row land in the same transaction (ADR-003); the comment above the call states exactly that and notes it never fires on the BR-107 update path (:109-111). Engagement consumes it to award feedback points (SessionFeedbackSubmittedPointsHandler, MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/Points/IntegrationEventHandlers/SessionFeedbackSubmittedPointsHandler.cs:29). This is also why the handler takes an injected TimeProvider (:24) rather than reading DateTime.UtcNow: the timestamp on the event is testable.
  • Concept introduced, hoisting a rule chain into a shared static so two surfaces cannot drift: neither eligibility check is written inline. SessionQuestionAnswerRules is an internal static holding both, and its summary says why: the single-answer and the batch submit use cases must enforce exactly the same contract (MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerRules.cs:8-13). Each helper takes the already-loaded aggregate as a parameter rather than looking it up, so the batch path can read the parent event once for a whole request (SessionQuestionAnswerRules.cs:16-19). [Rubric §15, Best Practices & Code Quality]: BR-49 has one definition, and both endpoints move when it moves.
  • Walkthrough: five members, and the ordering between them is the rule hierarchy.
    • HandleAsync (:28-60) loads the session with its SessionQuestionAnswers and asTracking: true (:32-37), because both the upsert lookup and the subsequent mutation need the children tracked. A missing session is a stamped Error.NotFound (:38-39).
    • ValidateSessionEligibilityAsync (:62-71) reads the parent Event through its own repository (:66-67) and hands both aggregates to SessionQuestionAnswerRules.EnsureSessionAcceptsFeedback (:69-70), which chains SessionInvariants.EnsureNotServiceSession (BR-91, a break or a lunch slot takes no feedback, SessionQuestionAnswerRules.cs:27, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:94), SessionInvariants.EnsureStatusIsEligible (BR-49, the same allow-list PublicSessionStatusSpecification expresses for reads, SessionQuestionAnswerRules.cs:34, defined at SessionInvariants.cs:109), a null-event NotFound (SessionQuestionAnswerRules.cs:41-44), and EventInvariants.EnsureEventIsPublished (BR-108, SessionQuestionAnswerRules.cs:46, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:151).
    • ValidateQuestionAsync (:73-82) loads the Question (:77-78) and delegates to SessionQuestionAnswerRules.EnsureAnswerIsValid (:80-81), which rejects a question that does not exist or whose QuestionEntity is not "Session" with a validation error coded Question.NotFoundOrWrongEntity (BR-128, SessionQuestionAnswerRules.cs:59-66), then hands the answer text to QuestionInvariants.EnsureAnswerValueMatchesQuestionType (BR-124, SessionQuestionAnswerRules.cs:69-70, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:118).
    • UpdateExistingAnswerAsync (:84-97) calls session.UpdateSessionQuestionAnswer(existingAnswer.Id, command.AnswerValue) (:90, aggregate method at Session.cs:508), saves, and maps the same tracked instance back out, so the response carries the new value.
    • CreateNewAnswerAsync (:99-117) calls session.AddSessionQuestionAnswer(...) (:105, aggregate method at Session.cs:484), raises the integration event, saves, and maps the child the aggregate returned. Both branches emit the same LogQuestionAnswerAddedToSession line (:119-120), so the log does not distinguish an insert from an update.
  • Why it's built this way: eligibility rules are shared with the batch path, so the handler composes helpers instead of copying conditions, and every check returns a Result that folds into the same failure channel (ADR-013). The branch on an existing answer is also why this slice keeps a hand-written HandleAsync instead of subclassing AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO>: that base's Apply hook returns one child from one aggregate call (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:70), which an upsert with two different aggregate methods cannot express. [Rubric §3, Clean Architecture]: the two cross-aggregate reads go through IUnitOfWork repositories, never EF types.
  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into SessionQuestionAnswersController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:78), whose whole surface requires an authenticated caller (:75), from the [Idempotent] POST /SessionQuestionAnswers action (:166-173). Covered by AddSessionQuestionAnswerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerHandlerTests.cs:15).
  • Caveats / not-in-source: currentUserService.UserId!.Value (:52) is null-forgiving. Nothing inside this handler enforces that a user id is present; the guarantee comes from the controller policy, so a caller reaching this code with no id would fault rather than fail gracefully. The three validation steps each issue their own round-trip (session, event, question), which is three reads before any write on the create path.

AddSessionSpeakerCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommandValidator.cs:8 · Level 10 · class (sealed)

  • What it is: a one-rule FluentValidation validator for AddSessionSpeakerCommand, rejecting a missing speaker id before the handler touches the database.
  • Depends on: FluentValidation's AbstractValidator<T> closed over the command (AddSessionSpeakerCommandValidator.cs:1, :8), and the SpeakerIdentifierType alias for the default comparison.
  • Concept reinforced, validation at the command boundary: the validation decorator runs every registered AbstractValidator<TCommand> before the handler (ADR-014), which is why AddSessionSpeakerHandler contains no shape checks on its inputs. Discovery is by convention: the module's ScanModuleApplicationServices<ClassReference>() call registers validators alongside handlers and mappers in one line (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133). [Rubric §24, Forms, Validation and UX Safety] assesses whether validation is present and applied before business logic; it is, and the failure surfaces as a structured Result rather than an exception.
  • Walkthrough: an expression-bodied constructor (AddSessionSpeakerCommandValidator.cs:10-13): RuleFor(x => x.SpeakerId).NotEqual(default(SpeakerIdentifierType)).WithMessage("Speaker ID is required."). Because the identifier alias is int, default is 0, and this is the module's standard idiom for "a value-typed id was not supplied".
  • Why it's built this way: the interesting rule, that a speaker cannot be attached twice, cannot live here. Duplicate detection needs the session's existing speaker list, so it is an aggregate invariant instead (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:315-329, error code Session.Speaker.Duplicate at :322). The split is deliberate: the validator checks shape, the aggregate checks state. [Rubric §4, DDD].
  • Where it's used: discovered by the convention scan (DependencyInjection.cs:133) and invoked by the validation decorator ahead of AddSessionSpeakerHandler. Covered by AddSessionSpeakerCommandValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:87).
  • Caveats / not-in-source: SessionId is not validated. A command with SessionId == 0 passes validation and fails as Error.NotFound from the inherited load (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:111-112), a correct outcome reached by a slower path than the speaker-id check gets.

AddSessionSpeakerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.AddSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerHandler.cs:16 · Level 10 · class (sealed partial)

  • What it is: the handler that adds a speaker association to a session. It is a five-override subclass of the framework's shared add-a-child workflow, with no orchestration code of its own.
  • Depends on: AddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO> closed over AddSessionSpeakerCommand, Session, SessionIdentifierType, SessionSpeaker, and SessionSpeakerDTO (AddSessionSpeakerHandler.cs:20); IUnitOfWork, passed to the base (:17, :20); the concrete SessionSpeakerDTOMapper (:18); Result.
  • Concept introduced, the shared load-delegate-save workflow and its five hooks: this is the canonical child-mutation handler in the module and worth reading once carefully, because several siblings in this chapter repeat it. The workflow itself lives in the framework (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:102-126): resolve the parent repository, load it tracked with the declared includes, fail NotFound when it is gone, call Apply, save only on success, log, and answer with MapChild. The module supplies only the five aggregate-specific facts. [Rubric §3, Clean Architecture] assesses whether the application layer stays free of business rules: this class makes no decisions at all. [Rubric §2, Design Patterns]: it is a template method, and its value is that the ordering of load, guard, mutate, save cannot be got wrong per handler. [Rubric §6, CQRS and Event-Driven]: the domain event is raised inside the aggregate (Session.cs:336) and dispatched by the unit of work, not by the handler. See ADR-099.
  • Walkthrough: a primary constructor and five overrides.
    • Constructor parameters (AddSessionSpeakerHandler.cs:16-20): unit of work, mapper, logger. Note the mapper is injected as the concrete SessionSpeakerDTOMapper rather than through the mapper interface, which is what makes the hand-written plural MapToDTOs reachable at other call sites; here only the singular is used.
    • Includes (:23) returns [nameof(Session.SessionSpeakers)]. The base declares this member abstract on purpose (ChildEntityHandlerBase.cs:56): loading the child collection is what makes the aggregate's duplicate check meaningful, so naming it has to be a deliberate act rather than an inherited default. An unloaded collection would turn a double submit into a raw unique-index 409 instead of a clean refusal (ChildEntityHandlerBase.cs:106-107).
    • ParentId (:26) projects command.SessionId, which is the key the base loads by.
    • Apply (:29-30) is the one line where the rules live: parent.AddSessionSpeaker(command.SessionSpeakerId, command.SpeakerId). The aggregate rejects a duplicate non-deleted association with Session.Speaker.Duplicate (Session.cs:322), delegates row creation to SessionSpeaker.Create, and raises SessionSpeakerChanged with DomainEntityState.Added (Session.cs:336). A failure short-circuits before the save (ChildEntityHandlerBase.cs:115-116).
    • MapChild (:33) turns the returned SessionSpeaker into its DTO. The base takes a hook rather than an injected DTO mapper because the DTO belongs to the child entity, whose identifier type is usually not the parent's (ChildEntityHandlerBase.cs:20-24).
    • LogAdded (:36-37) forwards to LogSpeakerAddedToSession, a [LoggerMessage] source-generated partial (:39-40). The base's default is a no-op, because logging is per-module vocabulary (ChildEntityHandlerBase.cs:84-87).
  • Why it's built this way: returning the created DTO rather than bare success lets the controller answer 201 Created with a location route pointing at the new join row (SessionSpeakersController.cs:182-185), which is what the generic create action does for aggregates and what a hand-written child create has to do for itself.
  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into SessionSpeakersController as addHandler (SessionSpeakersController.cs:50) and called at SessionSpeakersController.cs:169-171, after which the controller evicts the sessions output-cache tags (SessionSpeakersController.cs:181). Covered by AddSessionSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerHandlerTests.cs:12).
  • Caveats / not-in-source: nothing here checks that SpeakerId refers to an existing speaker. A well-formed id for a speaker that does not exist reaches the database and fails there as a foreign-key violation, not as a Result. The handler also leaves OnAddedAsync (ChildEntityHandlerBase.cs:98-99) at its default, so there is no post-commit publish on this path.

GetNowNextHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.NowNext · MMCA.ADC.Conference.Application/Sessions/UseCases/NowNext/GetNowNextHandler.cs:20 · Level 10 · class (sealed)

  • What it is: the read handler behind the now-next snapshot. It picks an event, filters that event's sessions down to the publicly exportable ones, and splits them into "running at this instant" and "the next batch to start".
  • Depends on: IQueryHandler<in TQuery, TResult> closed over GetNowNextQuery and Result<NowNextDTO> (GetNowNextHandler.cs:22); IUnitOfWork (:21); TimeProvider from the BCL (:22); CalendarExportMapper for IsExportable and ToUtc (:1, :48, :81-82); CurrentEventSelector for the live window and the current-or-next rule (:68, :101); the Event and Session aggregates; NowNextSessionDTO.
  • Concept introduced, injecting the clock: the handler takes TimeProvider and reads timeProvider.GetUtcNow() once at the top (GetNowNextHandler.cs:29), then uses that single instant for every comparison in the method. Two consequences. First, the snapshot is internally consistent: a session cannot be classified as both running and upcoming because the clock moved between two comparisons. Second, the whole "is it 9:30 on conference morning" question becomes a test input, which is exactly how GetNowNextHandlerTests pins its scenarios (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/NowNext/GetNowNextHandlerTests.cs:18). [Rubric §14, Testability] assesses whether ambient state is injected rather than reached for; a DateTime.UtcNow in this method would make the behavior untestable.
  • Concept introduced, wall clock versus instant: a conference schedule is authored in local wall-clock time, but "is it running now" is a question about instants. The handler resolves the event's TimeZone string to a TimeZoneInfo (:45) and converts each session's stored wall-clock StartsAt and EndsAt to a DateTimeOffset through CalendarExportMapper.ToUtc (:81-82), which also shifts spring-forward gap times ahead one hour so an invalid local time still yields an instant (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/ExportCalendar/CalendarExportMapper.cs:47-56). NowNextSessionDTO then carries both forms, local for printing on a badge or widget and UTC for callers doing their own math (:74-82). [Rubric §27, Internationalization] in its time-zone sense: the displayed value is the event's zone, never the server's.
  • Concept introduced, refusing to swallow a data defect: the time-zone lookup has no fallback. The comment at :43-44 states the reasoning: EventInvariants.EnsureTimeZoneIsValid guards every write path (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:82), so a stored id always resolves, and an unresolvable one is a data defect that must surface rather than be papered over with UTC. [Rubric §13, Observability and Operability] assesses whether a broken invariant is visible: a TimeZoneNotFoundException here is loud, where a silent UTC fallback would render a schedule that is quietly hours wrong.
  • Walkthrough: one public method and two private helpers.
    • HandleAsync (GetNowNextHandler.cs:25-72) starts with the clock (:29), then calls SelectEventAsync and refuses anything missing or unpublished with Error.NotFound targeting Event (:31-36). That guard is the access control for this endpoint: both actions are [AllowAnonymous], so "published" is the only thing standing between an anonymous caller and an unannounced event's schedule. [Rubric §11, Security].
    • It loads every session for the event with no includes and no visibility specification (:38-40), builds a room-id-to-name dictionary from the already-included Rooms (:41), then resolves the time zone (:45).
    • Eligibility reuses the calendar-export rule rather than restating it: rows = sessions.Where(CalendarExportMapper.IsExportable) (:47-50), which means scheduled at both ends, not a service session, and status-eligible per BR-49 through the single SessionStatuses.IsEligible allow-list (CalendarExportMapper.cs:26-28). One definition, two public surfaces.
    • now is every row whose UTC window contains the instant, ordered by start then room name case-insensitively (:52-56). next is deliberately not "the single next session": the handler takes the minimum future start (:59-60) and returns every row sharing it (:61-66), with the comment stating the intent, so parallel tracks show together (:58).
    • isLive compares the instant against the event's live window from CurrentEventSelector.GetLiveWindowUtc (:68-69, defined at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:66), the same window the home surfaces use, and the payload is assembled at :71.
    • ToRow (:74-82) projects one session, resolving the room name through the dictionary and returning null when the session has no room (:78).
    • SelectEventAsync (:84-107) branches on the nullable id: an explicit id is a direct GetByIdAsync including Rooms (:92-94); otherwise it loads all published events with their rooms (:97-99) and hands them to CurrentEventSelector.SelectCurrentOrNext with accessor lambdas for start, end, and zone (:101-106, defined at CurrentEventSelector.cs:24). Passing accessors rather than an interface is what lets that selector serve both entities and DTOs across the module.
  • Why it's built this way: the file states the contract at :12-19, that eligibility and DST discipline are shared with the calendar export deliberately. A widget and an .ics download that disagreed about which sessions are public would be a visible defect, and the only way to guarantee they agree is to call the same predicate.
  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into EventsController as nowNextHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:56) and called from both now-next actions (EventsController.cs:178, :191). The payload is fetched over HTTP by NowNextService (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/NowNextService.cs:14), rendered by HappeningNow, and read by the Android NowNextWidgetProvider. Covered by GetNowNextHandlerTests.
  • Caveats / not-in-source: the query at :38-40 loads every session row for the event, then filters, projects, sorts, and buckets in memory (:47-66). Nothing is pushed to the database beyond the EventId predicate, so the cost scales with the event's total session count rather than with the handful of rows the snapshot returns. The two cache layers on the endpoint make that acceptable in practice, not correct in principle. Separately, ToRow dereferences session.StartsAt! and session.EndsAt! (:79-82); that is safe only because IsExportable already rejected null-scheduled sessions, a coupling the compiler cannot check.

SessionCreateRequestMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Create · MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestMapper.cs:11 · Level 10 · class (sealed)

  • What it is: the adapter that turns a validated SessionCreateRequest into a Session entity by calling the domain factory. It is one method long and contains no logic of its own.
  • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Session / SessionCreateRequest / SessionIdentifierType (SessionCreateRequestMapper.cs:11-12); Result; Session.Create.
  • Concept introduced, request-to-entity mapping is not DTO mapping: entity-to-DTO mapping is source-generated by Mapperly (ADR-001), because it is a mechanical property copy with no rules. Going the other way is the opposite: constructing an entity is where invariants are enforced, so it cannot be generated. This class is therefore hand-written and does exactly one thing, forward the request's fields to the factory, so that Session.Create remains the only path into a valid Session. [Rubric §4, DDD] assesses whether entities can be constructed in an invalid state; here they cannot, because the mapper has no other constructor available to it. [Rubric §2, Design Patterns]: this is an adapter, and its value is precisely that it has no behavior of its own to disagree with the factory.
  • Walkthrough: CreateEntityAsync (SessionCreateRequestMapper.cs:15-36) guards its argument with ArgumentNullException.ThrowIfNull (:17), then returns Task.FromResult(Session.Create(...)) with fourteen positional arguments plus isInformed: and isConfirmed: passed by name (:19-35). The two named arguments matter: Session.Create declares them as trailing optional parameters after the optional roomId (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:179-181), so naming them is what lets the call skip nothing and stay readable. The method is async in signature only: it returns a completed task because nothing here awaits, which keeps the interface uniform for mappers that do need I/O without paying a state machine for the ones that do not. Validation happens inside the factory, which combines three invariant checks before allocating (Session.cs:183-186: title validity, end-after-start, optional text lengths) and returns Result.Failure<Session> with the combined errors when any fails.
  • Why it's built this way: the generic create pipeline needs a uniform way to get from some request type to some entity, and the only thing that can vary per entity is which factory to call with which fields. Isolating that in a one-method class means the pipeline never sees a domain constructor.
  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into CreateSessionHandler through the interface, not the concrete type (CreateSessionHandler.cs:25), and invoked by the framework's CreateCoreAsync (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:90). Covered by SessionCreateRequestMapperTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/Create/SessionCreateRequestMapperTests.cs:6).
  • Caveats / not-in-source: the mapper passes request.Id into a SessionIdentifierType? parameter (SessionCreateRequestMapper.cs:20), so a request whose Id is still 0 would reach the factory as 0 rather than as null. That does not happen on the live path, because CreateSessionHandler replaces a default id with a computed one in its PrepareAsync override before the mapper runs (CreateSessionHandler.cs:79-95), but the mapper itself does not enforce it. See also the Duration property this method drops, noted under SessionCreateRequest.

SessionCreateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Create · MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestValidator.cs:7 · Level 10 · class (sealed)

  • What it is: the input validator for SessionCreateRequest. Its body is two Include calls and nothing else (SessionCreateRequestValidator.cs:9-17).
  • Depends on: FluentValidation's AbstractValidator<T> (:1, :7); SessionFieldRules<T> and SessionEventIdRules<T> from MMCA.ADC.Conference.Application.Sessions.Validation (:2).
  • Concept reinforced, composing validators with Include, and where the shared set stops: FluentValidation's Include folds another validator's rules into this one as though they had been written inline. The seven field rules the create and update requests share are already folded once, inside SessionFieldRules<T>, which is generic over T : ISessionFieldsRequest and reads its properties through the interface rather than through per-call selectors (MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:111-124). So this validator includes that one set (:11) and then adds only its own per-operation delta. [Rubric §15, Best Practices & Code Quality] assesses whether a rule has a single home: change the title constraint once, in SessionTitleRules<T> via SessionFieldRules<T>, and both request paths move together. [Rubric §1, SOLID]: each rule set is one reason to change, and the interface constraint is the smallest thing that makes the shared set generic.
  • Walkthrough: the constructor (SessionCreateRequestValidator.cs:9-17) has two statements.
    • Include(new SessionFieldRules<SessionCreateRequest>()) (:11) pulls in title, description, status, live URL, recording URL, accessibility info, and resource links in that order (SessionValidationRules.cs:117-123). Title is required plus max-length (SessionValidationRules.cs:13-18); the other six are optional-string max-length rules bounded by SessionInvariants constants.
    • Include(new SessionEventIdRules<SessionCreateRequest>(p => p.EventId)) (:16) is the create-only delta, and the comment says why (:13-15): a session is scheduled inside an event, and the event it belongs to is chosen exactly once. BR-140 makes it immutable afterwards, so the update path validates it nowhere and UpdateSessionHandler rejects a change instead. The rule itself is a RequiredIdRules specialization carrying the error code Session.EventId.Required (SessionValidationRules.cs:24-29).
  • Why it's built this way: the parallel SessionUpdateRequestValidator includes the same SessionFieldRules<T> closed over its own request type and adds a different delta. Parameterizing the shared set by the request type, constrained to ISessionFieldsRequest, is what makes that reuse possible across two records that are otherwise unrelated.
  • Where it's used: discovered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133) and run by the validation decorator ahead of CreateSessionHandler (ADR-014). Covered by SessionCreateRequestValidatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCreateRequestValidatorTests.cs:7).
  • Caveats / not-in-source: the validator says nothing about StartsAt versus EndsAt. Ordering is a domain invariant, checked by SessionInvariants.EnsureEndsAtIsAfterStartsAt inside the factory (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:185, defined at SessionInvariants.cs:126), so an inverted range is rejected one layer later than a too-long title is. Room assignment is likewise absent here and enforced by the handler (CreateSessionHandler.cs:100-122).

RemoveSessionCategoryItemHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionCategoryItem · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemHandler.cs:13 · Level 11 · class (sealed partial)

  • What it is: the category-detach handler, and the member of the remove family that has to cope with a caller who does not know the parent id.
  • Depends on: RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType> closed over RemoveSessionCategoryItemCommand, Session, and SessionIdentifierType (RemoveSessionCategoryItemHandler.cs:16); IUnitOfWork, passed to the base (:14, :16); IRepository<TEntity, TIdentifierType> as the LoadAsync parameter type (:26); Result; the SessionCategoryItem child.
  • Concept reinforced: the shared workflow taught on AddSessionSpeakerHandler, minus the DTO. Removals return a bare Result because the controller answers 204 No Content (SessionCategoryItemsController.cs:202), so there is nothing to map and no mapper to inject. The base chains through MutateEntityHandlerBase to MutateCoreAsync (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:271-308), which loads, guards NotFound, mutates, saves, and logs; RemoveChildEntityHandlerBase adds only one thing, re-declaring Includes as abstract, because a remove that cannot see the collection cannot find the child and would report a wrong NotFound (ChildEntityHandlerBase.cs:148-152).
  • Concept introduced, resolving an aggregate root from a child id: the DELETE endpoint takes the session id as an optional query parameter, so a caller that omits it model-binds SessionId to 0 without a 400 (RemoveSessionCategoryItemHandler.cs:32-34). This is exactly the case the base's LoadAsync hook exists for: it is virtual, defaulting to a by-id load, and is meant to be overridden only when the command can address the aggregate some other way (MutateEntityHandlerBase.cs:144-159). The override branches on the unset id (:35) and queries sessions by a predicate over the child collection, s => s.SessionCategoryItems.Any(sci => sci.Id == command.SessionCategoryItemId), including the collection and tracking it (:37-41); otherwise it loads directly by id (:44-48). Either way the rest of the workflow is identical, so the aggregate boundary is preserved: the removal is still performed by the root, never by reaching into a child repository. [Rubric §4, DDD] assesses exactly this, that children are mutated through their root. [Rubric §9, API and Contract Design]: the optional query parameter is what makes the two shapes one endpoint rather than two.
  • Walkthrough: four overrides and one log method.
    • Includes (:19) returns [nameof(Session.SessionCategoryItems)], the collection the aggregate's remove method searches.
    • EntityId (:22) projects command.SessionId, used by the default load path.
    • LoadAsync (:25-49) is the branch above, guarded by ArgumentNullException.ThrowIfNull(repository) (:30) and reusing the inherited Includes and AsTracking in both arms so the two paths cannot drift. AsTracking is true by default (MutateEntityHandlerBase.cs:76), which is required because the mutation soft-deletes a loaded child.
    • MutateAsync (:52-56) is one line wrapped in Task.FromResult: entity.RemoveSessionCategoryItem(command.SessionCategoryItemId) (aggregate method at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:457). A failure short-circuits before the save (MutateEntityHandlerBase.cs:294-296).
    • LogMutated (:59-60) forwards to the source-generated LogCategoryItemRemovedFromSession (:62-63).
  • Why it's built this way: the fallback exists because the UI reuses one generic delete affordance across every entity, and that component knows only the row's own id. Teaching the server to resolve the parent is cheaper than special-casing the client, and it keeps the endpoint usable by callers that do have the session id. Putting it in a LoadAsync override rather than in a hand-written handler is what keeps the rest of the workflow (the NotFound shape, the save ordering, the optimistic-concurrency stamp at MutateEntityHandlerBase.cs:291) shared with every other mutation.
  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into SessionCategoryItemsController as removeHandler (SessionCategoryItemsController.cs:51) and called at SessionCategoryItemsController.cs:192-194, after which the controller evicts the sessions, categories, and conference output-cache tags (SessionCategoryItemsController.cs:201). Covered by RemoveSessionCategoryItemHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemHandlerTests.cs:12).
  • Caveats / not-in-source: on the direct path the handler takes SessionId on faith. Passing a valid join id together with the wrong session id yields a not-found from the aggregate rather than a cross-session removal, but nothing here verifies the pairing before the load. The fallback path uses FirstOrDefaultAsync over a collection predicate (:37-41), which is correct because a join id belongs to exactly one session, but the query does not say so. The log statement also records command.SessionId (:60), which is 0 on the fallback path, so the emitted event names the join row correctly and the session as zero.

CreateSessionHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.Create · MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:22 · Level 14 · class (sealed partial)

  • What it is: the session create handler, and the most involved handler in this chapter. On top of the shared create workflow it allocates application-assigned ids out of a reserved range, guards room assignment, and retries a bounded number of times when a concurrent create takes the id it computed.
  • Depends on: CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> closed over SessionCreateRequest, Session, SessionIdentifierType, and SessionDTO (CreateSessionHandler.cs:29-30); IUnitOfWork (:23); IServiceScopeFactory from Microsoft.Extensions.DependencyInjection (:24); IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (:25, implemented by SessionCreateRequestMapper); the concrete SessionDTOMapper (:26); IUniqueConstraintViolationDetector (:27); SessionInvariants for the reserved range; SessionRoomScheduling for BR-130; the Event aggregate.
  • Concept introduced, application-assigned ids in a reserved range: session primary keys are not database-generated, because the int PK is the Sessionize id (CreateSessionHandler.cs:76-78). An organizer-created session therefore needs an id that can never collide with one Sessionize will later import. The domain reserves the top of the range for that: SessionInvariants.ManualIdRangeStart is 999_999_000 and ManualIdRangeEnd is 999_999_999 (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:44, :47), so a thousand manual ids sit above anything Sessionize issues. The handler queries the existing rows in that window with ignoreQueryFilters: true (:81-85), takes Max + 1 or the range start when empty (:87-89), and fails with a plain Error.Failure coded Session.ManualIdRangeExhausted when the range is exhausted (:91-92). Ignoring the query filters is load-bearing: a soft-deleted session still occupies its id, so counting only visible rows would hand out an id the database already holds. [Rubric §8, Data Architecture] assesses whether the identity strategy matches the data's provenance; here an externally-owned key space forced the choice, and the reserved range is how the two writers coexist.
  • Concept introduced, retrying a lost id race in a fresh DI scope: computing Max + 1 and inserting is a read-then-write race, so two concurrent organizer creates can compute the same id. The handler accepts that and recovers instead of locking. MaxManualIdAttempts is 3 (:33). The HandleAsync override (:36-66) loops, and the catch filter engages only when attempts remain and the detector classifies the exception as a unique-constraint violation (:60). The subtle part is the retry, which does not reuse the ambient unit of work: scopeFactory.CreateAsyncScope() produces a fresh scope and a fresh IUnitOfWork (:56-57), because the ambient DbContext still tracks the insert that just failed and would replay it (:54-55). This is precisely the extension point the base documents: a manual-id retry variant overrides HandleAsync to wrap CreateCoreAsync in its retry loop and overrides PrepareAsync to recompute the id per attempt, and CreateCoreAsync takes the unit of work as a parameter so a retry can run against a fresh scope while reusing the whole workflow (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:29-36, :76-79). [Rubric §29, Resilience and Business Continuity] assesses whether transient contention is survived rather than surfaced; a bounded, condition-filtered retry is the shape that does not turn a real error into an infinite loop. [Rubric §12, Performance and Scalability]: the design trades a rare retry for never taking a table lock.
  • Concept introduced, classifying a provider error without referencing the provider: the Application layer references the domain and no data provider at all, so it cannot see SqlException.Number 2601 or 2627, nor the EF Core DbUpdateException that wraps them. Rather than match on the exception message, which is a provider and locale detail, the question is declared as IUniqueConstraintViolationDetector and answered in Infrastructure where the provider types already live (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUniqueConstraintViolationDetector.cs:9-29). A miss is safe rather than silent: an unclassified exception simply propagates (IUniqueConstraintViolationDetector.cs:26-29). [Rubric §3, Clean Architecture] assesses whether layer boundaries hold under pressure; they do here, and swapping engines swaps the implementation with every recovering handler unchanged.
  • Walkthrough: two overrides and two log methods; the create workflow itself is inherited.
    • HandleAsync (:36-66) first short-circuits: a caller-supplied id (a Sessionize import, for example) is respected as-is and gets a single attempt with no recomputation, because a collision there is a genuine caller error (:40-43). Otherwise the retry loop runs, calling the inherited CreateCoreAsync on the ambient unit of work for attempt one (:51-52) and on a scoped one thereafter (:56-58), logging a warning on each collision (:63).
    • PrepareAsync (:69-125) is the base's optional pre-map hook (CreateEntityHandlerBase.cs:114-118), and it runs once per attempt. It resolves the repository (:74), allocates the manual id when needed (:79-95, ending in command = command with { Id = nextId }, a copy rather than a mutation), then validates room assignment only when a room was requested (:100-122). That branch loads the parent Event with its Rooms untracked (:102-107), returns Error.NotFound targeting Event when it is missing (:108-109), and delegates to SessionRoomScheduling.ValidateRoomAssignmentAsync for BR-130 cross-event validation plus the double-booking guard (:111-119, defined at MMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:44, with excludeSessionId: null because nothing exists yet to exclude). The comment at :97-99 explains why the event is not loaded unconditionally: a room-less session has nothing to validate, and unlike update's BR-86 warning, create has no other use for the event.
    • Everything after PrepareAsync is the base's CreateCoreAsync (CreateEntityHandlerBase.cs:77-103): map through the request mapper, AddAsync then SaveChangesAsync via PersistAsync, call LogCreated, then map the SessionDTO.
    • LogCreated (:128) is the base's post-save hook, forwarding to the module's template. Two [LoggerMessage] partials close the file: LogSessionCreated at information level (:130-131) and LogManualIdCollision at warning level with the attempt counters (:133-134). The warning is the operational signal that the id race is happening more often than expected. [Rubric §13, Observability and Operability].
  • Why it's built this way: every complication in this file traces to one fact, that the session key space is shared with an external system. ADR-006 gives the module its own database, but not its own id authority for sessions. Given that, the reserved range prevents collision by construction, and the retry handles the only race the range cannot prevent. Expressing both as overrides on the shared base rather than as a hand-written handler is the point of ADR-099: the unusual parts stay visible and the ordinary parts stay shared.
  • Where it's used: registered by the convention scan (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into SessionsController as createHandler (SessionsController.cs:47), passed into AggregateRootEntityControllerBase (SessionsController.cs:57-58), and called from the overridden POST /Sessions action (SessionsController.cs:285), which is marked [Idempotent] so a retried request replays rather than creating twice (SessionsController.cs:280, ADR-017). Covered by CreateSessionHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/Create/CreateSessionHandlerTests.cs:16).
  • Caveats / not-in-source: the manual-id query loads every session row in the reserved range as entities and computes Max in memory (:81-89) rather than asking the database for the maximum. The range caps at a thousand rows, so the cost is bounded, but it is not a scalar query. The retry loop is also while (true) with its bound expressed only in the catch filter (:46, :60): correct as written, since a non-matching exception or an exhausted budget propagates, but the termination condition is not local to the loop header.

RemoveSessionQuestionAnswerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9 · Level 9 · record

  • What it is: the command that removes one attendee feedback answer from a session. Two positional ids: the owning SessionId and the SessionQuestionAnswerId (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9-11).
  • Depends on: ICacheInvalidating (RemoveSessionQuestionAnswerCommand.cs:11), the Session type for the prefix, and the SessionIdentifierType / SessionQuestionAnswerIdentifierType aliases (ADR-048).
  • Concept reinforced: the same two-id removal shape as RemoveSessionCategoryItemCommand. What is worth noticing is what this record does not carry: no caller identity. Ownership for BR-52 and BR-53 is resolved server side inside RemoveSessionQuestionAnswerHandler (RemoveSessionQuestionAnswerHandler.cs:70-78), so a client cannot claim to be an answer's author by shaping the request. [Rubric §11, Security] assesses whether identity ever travels as request data; here it does not.
  • Walkthrough: one member, CachePrefix => $"{typeof(Session).FullName}:" (RemoveSessionQuestionAnswerCommand.cs:13-14). The prefix names the Session rather than the answer, so one eviction after a successful removal clears every cached session projection that could still be showing the answer (ADR-026). The record contains no cache code: it declares what it invalidates, and CachingCommandDecorator<TCommand, TResult> does the work on success only (ADR-014).
  • Where it's used: constructed by the DELETE /SessionQuestionAnswers/{id} action of SessionQuestionAnswersController, route id plus [FromQuery] sessionId (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:228-236), on a controller whose whole surface requires only an authenticated caller (SessionQuestionAnswersController.cs:75) rather than the SessionsManage permission the other two junction controllers demand; handled by RemoveSessionQuestionAnswerHandler, injected at SessionQuestionAnswersController.cs:81.

RemoveSessionSpeakerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9 · Level 9 · record

  • What it is: the command that detaches a speaker from a session. Two positional ids: the owning SessionId and the SessionSpeakerId join row (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9-11).
  • Depends on: ICacheInvalidating (RemoveSessionSpeakerCommand.cs:11), the Session type for the prefix, and the SessionIdentifierType / SessionSpeakerIdentifierType aliases.
  • Concept reinforced: structurally identical to RemoveSessionCategoryItemCommand. Note the asymmetry with the matching Add command: AddSessionSpeakerCommand takes a nullable child id (the database may assign it), while every Remove command takes a required one. There is nothing to generate on removal, so a null there would only be a way to express "remove nothing". The behavioral difference is downstream, not here: RemoveSessionSpeakerHandler treats a SessionId of default as "not supplied" and resolves the owning session from the join id instead (RemoveSessionSpeakerHandler.cs:35-42), which is only possible because SessionIdentifierType is a value type with a meaningless zero.
  • Walkthrough: one member, CachePrefix => $"{typeof(Session).FullName}:" (RemoveSessionSpeakerCommand.cs:13-14), identical to the Add side, so attaching and detaching a speaker evict the same set of cached session projections.
  • Where it's used: constructed by the DELETE /SessionSpeakers/{id} action of SessionSpeakersController, route id plus [FromQuery] sessionId (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSpeakersController.cs:188-196), on a controller gated at the class level by the SessionsManage permission (SessionSpeakersController.cs:47, ADR-020); handled by RemoveSessionSpeakerHandler, injected at SessionSpeakersController.cs:51.

SponsorCreateRequest

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:12 · Level 9 · record class

  • What it is: the body a POST /Sponsors binds to, and, without any translation step, the command the CQRS pipeline dispatches. Twelve init-only members describe a sponsor or exhibitor; one extra member, CachePrefix, tells the pipeline what to evict when the write succeeds (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequest.cs:15).
  • Depends on: three interfaces, all declared on one line (SponsorCreateRequest.cs:12). Two are framework markers: ICreateRequest and ICacheInvalidating. The third, ISponsorFieldsRequest, is the module's own (MMCA.ADC.Conference.Application/Sponsors/Validation/ISponsorFieldsRequest.cs:12). It also depends on the Sponsor entity type (referenced only through typeof for the cache prefix) and the SponsorTier enum. SponsorIdentifierType and EventIdentifierType are both module aliases for int.
  • Concept introduced, the request that is also the command: most codebases carry a request DTO at the edge and translate it into an internal command. Here the two collapse. The controller declares ICommandHandler<SponsorCreateRequest, Result<SponsorDTO>> directly (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:42) and passes the same type as the TCreateRequest argument of its generic base (SponsorsController.cs:49), so a single declaration is the OpenAPI schema, the validation target, the mapper input, and the cache-invalidation carrier. The cost is that a wire concern and a use-case concern share one type; the benefit is that there is exactly one place to add a field. [Rubric §5, Vertical Slice] assesses whether a feature is expressible as one thin, self-contained slice: the sponsor create slice is this file plus a validator, a mapper, and a handler, all in the same folder. [Rubric §9, API and Contract Design] assesses whether the published contract is explicit: required string Name (:21) is the only member the binder will not default, and every other member is optional by construction.
  • Concept introduced, the shared-fields interface: ISponsorFieldsRequest declares the eight members the create and the update request validate identically (ISponsorFieldsRequest.cs:15-36). It exists purely so SponsorFieldRules<T> can be written once against a constraint rather than once per request type (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:139-141). What is off the interface matters as much as what is on it: EventId, Id, Tier, and IsExhibitor are absent. The file states the reason for EventId (ISponsorFieldsRequest.cs:9-10): only the create request carries it, because moving a sponsor between events is a create plus a delete. [Rubric §15, Best Practices & Code Quality] assesses whether shared shape is expressed structurally rather than duplicated: two request types, one rule list, and the compiler enforces the overlap.
  • Walkthrough: the members in the order they matter.
    • CachePrefix => $"{typeof(Sponsor).FullName}:" (:15) is the entity's fully-qualified name plus a colon. All three sponsor mutations declare that same prefix, so they evict one shared namespace of keys. The eviction is performed by CachingCommandDecorator<TCommand, TResult> on success only.
    • Id (:18) is documented as database-generated with caller-supplied values ignored, and the factory is what makes that true: it consults typeof(Sponsor).IsIdValueGenerated and substitutes default whenever the store owns the key (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:126-131). Nothing rejects a supplied id; it is simply discarded.
    • Name (:21) is required, so an omitted name fails model binding before any validator runs. Tier (:24) is the enum, Sort (:42) the within-tier display order, EventId (:45) the owning event, IsExhibitor (:48) and BoothNumber (:51) the expo-floor pair, and LogoUrl, Description, WebsiteUrl, LinkedInUrl, TwitterHandle (:27-39) the optional branding strings.
    • Every member is init, so once the binder has filled the instance the validator, the mapper, and the handler all see the same frozen values.
  • Why it's built this way: the shape mirrors SponsorDTO member for member minus the concurrency token, which keeps the round trip (POST a request, receive a DTO) readable without a mapping table (ADR-001). Declaring cache invalidation as a property rather than calling a cache API keeps the Application layer free of cache infrastructure, which is what [Rubric §12, Performance & Scalability] looks for: the concern is declared once, and one decorator implements it for every command that declares it.
  • Where it's used: SponsorsController takes the handler for it in its constructor (SponsorsController.cs:42) and overrides CreateAsync to add the SponsorsManage permission attribute and the output-cache eviction (SponsorsController.cs:162-171); SponsorCreateRequestValidator validates it, SponsorCreateRequestMapper turns it into an entity, and CreateSponsorHandler persists it. The framework's generic CRUD registration also closes over it (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:145).
  • Caveats / not-in-source: sponsors sit behind two independent caches, and this property addresses only one. CachePrefix drives the framework's prefix eviction; the ASP.NET output cache in front of the public sponsor reads is a separate store the controller evicts by tag in the same action (SponsorsController.cs:169). Removing either half leaves stale sponsor data visible somewhere.

SponsorDTOMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.DTOs · MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:13 · Level 9 · class (sealed, partial)

  • What it is: the entity-to-DTO mapper for sponsors, and the simplest one in the module: no redaction, no conditional projection, just the Mapperly-generated copy of Sponsor into SponsorDTO, because sponsor data is bought placement and is public by design (MMCA.ADC.Conference.Application/Sponsors/DTOs/SponsorDTOMapper.cs:8-11).
  • Depends on: IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> closed over Sponsor / SponsorDTO / SponsorIdentifierType (:14), and the Mapperly source generator via [Mapper] from Riok.Mapperly.Abstractions (:4, :12).
  • Concept reinforced, source-generated mapping: the pattern is introduced in Group 12 and governed by ADR-001. MapToDTO is declared partial with no body (:17); Mapperly reads both types at compile time and emits the property-by-property assignment, so a member added to the entity but not to the DTO surfaces as a build diagnostic rather than as a silently missing field at runtime. [Rubric §15, Best Practices and Code Quality] assesses whether repetitive code is generated rather than hand-maintained: the assignments exist, and none of them are in this file.
  • Walkthrough: two members.
    • public partial SponsorDTO MapToDTO(Sponsor entity); (:17) is the generated one. Because SponsorDTO also carries a RowVersion, the concurrency token rides along with the projection and is what a later conditional update has to echo back (ADR-035).
    • MapToDTOs (:20-24) is hand-written and, read side by side, is what the interface already provides as a default implementation: a null guard (:22) plus [.. entityCollection.Select(MapToDTO)] (:23). The duplication is not pointless. A C# default interface member is reachable only through the interface, and this mapper is injected by its concrete type into CreateSponsorHandler (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:18), which the framework's Scrutor scan supports by registering mappers AsSelfWithInterfaces (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:200-204). Re-declaring the method keeps both call shapes working.
  • Why it's built this way: contrast it with its sibling. SpeakerDTOMapper injects the current-user service and blanks the speaker's email for anyone who is not an Organizer. Sponsors have no such member, so this mapper needs no collaborators and stays a pure function. [Rubric §11, Security] assesses whether sensitive data is filtered at the boundary that owns it: here the boundary exists and has nothing to filter, which is a documented conclusion (:8-11) rather than an omission. [Rubric §30, Compliance and Data Governance] lands in the same place: nothing on the sponsor record is personal data.
  • Where it's used: registered by the module's convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133, which reaches the IEntityDTOMapper<,,> sweep at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:200-204); injected concretely into CreateSponsorHandler (CreateSponsorHandler.cs:18) and forwarded from there into the framework create workflow's dtoMapper slot (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:45, used at :101); and resolved through the interface by the closed-generic read service registered for sponsors, EntityQueryService<Sponsor, SponsorDTO, SponsorIdentifierType> (MMCA.ADC.Conference.Application/DependencyInjection.cs:94).
  • Caveats / not-in-source: the generated MapToDTO has no null guard, and the suite pins that: MapToDTO(null!) throws NullReferenceException (SponsorDTOMapperTests, MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/DTOs/SponsorDTOMapperTests.cs:75-81), while the hand-written collection overload throws the more conventional ArgumentNullException (SponsorDTOMapper.cs:22). Every caller in this codebase passes a materialized entity, so the asymmetry is documented behavior rather than a live failure mode.

UpdateSessionQuestionAnswerCommand

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10 · Level 9 · record (sealed)

  • What it is: a three-value command to change the text of one answer on one session's questionnaire. It carries the owning session id, the answer id, and the new text (MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommand.cs:10-13).
  • Depends on: ICacheInvalidating (:13) and the Session type, referenced only through typeof to build the cache prefix (:16). SessionIdentifierType and SessionQuestionAnswerIdentifierType are both int.
  • Concept reinforced, commands address the aggregate root: the pattern is taught in Group 05. Note what the first parameter buys. The REST route already identifies the answer (PUT /SessionQuestionAnswers/{id}), yet the command still requires SessionId, supplied from the request body (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:219). That is not redundancy: SessionQuestionAnswer is a child inside the Session aggregate, so the only legal way to mutate it is to load the root and go through it, and the root's id is what makes that load possible. It is also what lets UpdateSessionQuestionAnswerHandler keep the framework's default by-id load and skip the join-id fallback its remove siblings need. [Rubric §4, DDD] assesses whether aggregate boundaries are respected in the write model: the command's shape enforces the boundary before the handler even runs.
  • Walkthrough: a positional record with one computed member.
    • The three positional parameters SessionId, SessionQuestionAnswerId, and AnswerValue (:11-13) become init-only properties, so the command is immutable once constructed.
    • CachePrefix => $"{typeof(Session).FullName}:" (:16) names the Session, not the answer. Evicting the parent's namespace is what matters: nothing caches a bare answer, but a session read that includes its answers would otherwise keep serving the old text.
  • Why it's built this way: the handler's response type is a bare Result, not Result<T>, because a successful update returns nothing beyond a 204 (SessionQuestionAnswersController.cs:222-224). That choice is what selects the three-parameter MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> rather than its DTO-returning four-parameter sibling. Keeping the command a record also makes it structurally comparable, which is what the API tests lean on when they assert the handler was called with the values the route and body supplied.
  • Where it's used: constructed by SessionQuestionAnswersController.UpdateAsync (SessionQuestionAnswersController.cs:211-225), validated by UpdateSessionQuestionAnswerCommandValidator, and handled by UpdateSessionQuestionAnswerHandler.
  • Caveats / not-in-source: nothing checks that the SessionId in the body actually owns the {id} in the route. It does not need to: the handler loads the session named in the command and looks the answer up inside that aggregate's own collection, so a mismatched pair simply finds no child and returns NotFound (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:508-511).

ActivityNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Activities · MMCA.ADC.Conference.Application/Activities/ActivityNavigationPopulator.cs:12 · Level 10 · class (sealed)

  • What it is: the navigation populator for Activity. It declares one thing: how to hydrate an activity's parent Event reference when EF Core cannot reach it with .Include(). The class body is empty (MMCA.ADC.Conference.Application/Activities/ActivityNavigationPopulator.cs:24-25); the entire implementation is the descriptor list handed to the base constructor (:14-23).
  • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Activity (:14), FKNavigationDescriptor<TEntity, TChild, TChildId> closed over Activity / Event / EventIdentifierType (:16), IUnitOfWork forwarded untouched to the base (:12-14), and the Activity and Event entities.
  • Concept introduced in this group, FK back-reference hydration: Group 11 teaches the populator machinery; what this class introduces here is the other direction of it. A child-collection descriptor answers "give me the rows that point at me"; an FK descriptor answers "give me the one row I point at". The framework separates the two with a single boolean: FKNavigationDescriptor.RequiresChildren is hard-coded false (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23), and the base uses it to pick which caller flag gates the load, includeFKs rather than includeChildren (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36). [Rubric §2, Design Patterns] assesses whether behavior is factored into reusable shapes: this is Template Method configured by data, so a new navigation is a descriptor rather than a new query method. [Rubric §7, Microservices Readiness] assesses whether code survives a physical split: the whole reason this file exists is that a join is unavailable when parent and child live in different data sources (ADR-006, ADR-018).
  • Walkthrough: one descriptor, four settings, and a base algorithm worth following once.
    • PropertyName = nameof(Activity.Event) (:18) is the match key. The base builds an ordinal HashSet of the property names the metadata provider flagged as unsupported and loads only descriptors whose name is in it (DeclarativeNavigationPopulator.cs:30-37). The name comes from the navigation property itself, public Event? Event { get; private set; } (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:58), which carries a bare [Navigation] attribute; with IsCollection left at its default, metadata discovery files it in the foreign-key bucket (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:78), which is exactly what makes RequiresChildren => false the right gate.
    • ParentKeySelector = e => e.EventId (:19). The descriptor types this as Func<TEntity, TChildId?> (FKNavigationDescriptor.cs:26), and Activity.EventId is a non-nullable int (Activity.cs:54), so the compiler widens it to int?. The nullable signature exists for entities whose FK is genuinely optional; here it can never be null.
    • ChildForeignKeySelector = child => child.Id (:20). Read that carefully: for an FK reference the "child foreign key" is the target's own primary key, because the predicate being built matches Event.Id against the set of Activity.EventId values.
    • AssignAction = (e, events) => e.SetEvent(events.FirstOrDefault()) (:21) calls a public aggregate mutator (Activity.cs:192) rather than assigning the property directly. The navigation itself has a private set (Activity.cs:58), so hydration goes through a named method the entity owns, which is the same discipline the child-collection populators follow. [Rubric §4, DDD]: even an infrastructure-driven write enters the entity through its own API.
    • The load is NavigationLoader.LoadFKPropertyAsync (FKNavigationDescriptor.cs:39-45), and it is deliberately batched: collect the distinct non-null keys (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:58), return early assigning empty lists when there are none (:63), build child => parentIds.Contains(child.Id) as an expression tree (:71), run one GetAllAsync with that predicate against the read repository (:80), group the results into a dictionary (:89), and assign per parent (:92). One query for a page of activities, not one per activity.
    • Two guards mean this usually costs nothing: PopulateAsync returns immediately when the entity list is empty or when the metadata reported no unsupported includes at all (DeclarativeNavigationPopulator.cs:27-28). On a topology where activities and events share a source, that second guard is always true and the populator never touches the database.
  • Why it's built this way: ADR-002 makes hydration a declaration in the Application layer rather than an EF concern, and NavigationMetadataProvider decides per navigation whether .Include() is available. Because that decision is configuration, this file is inert in the monolith and becomes the hydration path after a split, with no change to the controller, the query service, or the DTO. [Rubric §3, Clean Architecture]: there is no EF Core namespace anywhere in the file. [Rubric §12, Performance and Scalability]: the batched IN shape is what keeps a paged list from degrading into N+1.
  • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Activity>, ActivityNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:90) and consumed by the closed-generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registered on the next line (:88), which invokes it as part of the read pipeline. Its tests assert the DI shape and both empty-input guards without touching the unit of work (ActivityNavigationPopulatorTests, MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/ActivityNavigationPopulatorTests.cs:15-43).
  • Caveats / not-in-source: AssignAction runs for every parent, including those whose lookup found nothing, in which case FirstOrDefault() assigns null (NavigationLoader.cs:92-99). Combined with the soft-delete query filter the read repository applies (ADR-005), an activity whose owning event has been soft-deleted comes back with Event == null rather than as an error, and a caller that renders the event name has to handle that null.

CategoryItemNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories · MMCA.ADC.Conference.Application/Categories/CategoryItemNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the FK populator for CategoryItem, hydrating each item's parent Category reference. It is the only populator in the Conference module whose FK target is not Event.
  • Depends on: DeclarativeNavigationPopulator<TEntity> over CategoryItem (MMCA.ADC.Conference.Application/Categories/CategoryItemNavigationPopulator.cs:13), one FKNavigationDescriptor<TEntity, TChild, TChildId> closed over CategoryItem / Category / ConferenceCategoryIdentifierType (:15), and IUnitOfWork (:12).
  • Concept reinforced: identical in mechanism to ActivityNavigationPopulator, which teaches the FK descriptor, the includeFKs gate, and the batched loader in full. Only the four settings differ.
  • Walkthrough: PropertyName = nameof(CategoryItem.Category) (:17), matching the [Navigation]-attributed property on the entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:24); ParentKeySelector = e => e.CategoryId (:18), reading the get-only FK (CategoryItem.cs:27); ChildForeignKeySelector = child => child.Id (:19), the parent category's own primary key; and AssignAction = (e, categories) => e.SetCategory(categories.FirstOrDefault()) (:20), which goes through the entity's public mutator (CategoryItem.cs:89) because the navigation has a private set. The generic argument ConferenceCategoryIdentifierType is the module alias for int, named with the Conference prefix because the entity type is the very generic Category.
  • Why it's built this way: same rationale as its siblings (ADR-002). [Rubric §15, Best Practices & Code Quality]: the difference between two entities that need parent hydration is four lines of configuration, not two query classes.
  • Where it's used: registered as INavigationPopulator<CategoryItem> (MMCA.ADC.Conference.Application/DependencyInjection.cs:100) alongside the closed-generic read service for category items (:98). Tests: CategoryItemNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/CategoryItemNavigationPopulatorTests.cs:15-43).
  • Caveats / not-in-source: ConferenceCategoryNavigationPopulator is the mirror image of this file, loading items from the category side. The two are independent registrations, so a read that starts at either end hydrates the other without either populator knowing about its counterpart.

ConferenceCategoryNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Categories · MMCA.ADC.Conference.Application/Categories/ConferenceCategoryNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the navigation populator for the Category aggregate, hydrating its one child collection, CategoryItems. Like every populator in this module the body is empty (MMCA.ADC.Conference.Application/Categories/ConferenceCategoryNavigationPopulator.cs:23-24) and the behavior is the descriptor passed to the base (:13-22).
  • Depends on: DeclarativeNavigationPopulator<TEntity> over Category (:13), one ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> closed over Category / ConferenceCategoryIdentifierType / CategoryItem / CategoryItemIdentifierType (:15), IUnitOfWork (:12), and the Category and CategoryItem entities.
  • Concept introduced in this group, declarative child loading: the counterpart to the FK direction taught on ActivityNavigationPopulator. Three things change. First, ChildNavigationDescriptor.RequiresChildren is hard-coded true (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25), so the load is gated on the caller's includeChildren flag (DeclarativeNavigationPopulator.cs:36), matching the [Navigation(IsCollection = true)] attribute that puts the property in the child bucket during discovery (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:78). Second, the key pair inverts: the parent supplies its own primary key and the child supplies the FK that points back. Third, the assignment cannot be a property set, because the collection is exposed as IReadOnlyCollection<CategoryItem> over a private list (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:31).
  • Walkthrough: one descriptor.
    • PropertyName = nameof(Category.CategoryItems) (:17), ParentKeySelector = e => e.Id (:18), ChildForeignKeySelector = child => child.CategoryId (:19, the FK declared at MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:27).
    • AssignAction = (e, categoryItems) => e.SetCategoryItems(categoryItems) (:20) calls an internal aggregate mutator (Category.cs:201), reachable from this assembly only because the Domain project grants InternalsVisibleTo to MMCA.ADC.Conference.Application. Nothing outside the module can replace the collection.
    • The load routes through NavigationLoader.LoadChildrenPropertyAsync (ChildNavigationDescriptor.cs:41, implemented at MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:118), which is one batched WHERE CategoryId IN (...parentIds) query per descriptor, not one per category.
  • Why it's built this way: the naming deserves a note. The type is ConferenceCategoryNavigationPopulator while the entity is plain Category, and the identifier alias is ConferenceCategoryIdentifierType: Category is a word several modules would claim, so the module-qualified prefix appears on everything that is registered or aliased globally, while the entity keeps its natural name inside its own namespace. [Rubric §15, Best Practices & Code Quality] cares about exactly this kind of collision avoidance. The hydration rationale is ADR-002 and ADR-006, as for every populator here.
  • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Category>, ConferenceCategoryNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:80), beside a closed-generic read service (:78) and the module's own delete handler (:79). Tests: ConferenceCategoryNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/ConferenceCategoryNavigationPopulatorTests.cs:15-43).
  • Caveats / not-in-source: nothing in the descriptor filters soft-deleted items. That exclusion comes from the global query filter on the read repository the loader resolves (ADR-005), not from this file.

CreateSponsorHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:15 · Level 10 · class (sealed, partial)

  • What it is: the command handler for SponsorCreateRequest, and a good demonstration of how little a create handler has to contain. The whole class is a constructor, one two-line override, and a source-generated log method (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:15-28). Every step of the create itself lives in the framework base it derives from.
  • Depends on: CreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO> closed over SponsorCreateRequest / Sponsor / SponsorIdentifierType / SponsorDTO (:20-21); IUnitOfWork (:16), IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> (:17, satisfied at runtime by SponsorCreateRequestMapper), and the concrete SponsorDTOMapper (:18), all three forwarded straight into the base constructor; plus ILogger<CreateSponsorHandler> (:19), which is the one dependency the base does not take.
  • Concept introduced, the workflow base class: the base is an abstract class that itself implements ICommandHandler<in TCommand, TResult> (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:46). That detail is load-bearing and the file says why (CreateEntityHandlerBase.cs:17-23): because the concrete subclass is what carries the closed interface, the module's Scrutor scan keeps discovering it, the decorator pipeline keeps wrapping it, and Scrutor never registers the abstract base. Inheritance here buys code reuse without changing the registration story. [Rubric §1, SOLID] assesses whether shared behavior is factored out without leaking: the subclass overrides one hook and inherits eight steps. [Rubric §2, Design Patterns]: Template Method again, this time over a write workflow instead of over navigation loading.
  • Walkthrough: two members here, and the inherited pipeline behind them.
    • LogCreated(Sponsor entity) (:24) is the single override, forwarding to the module's own [LoggerMessage] partial. The base declares LogCreated as a no-op virtual precisely so logging stays per-module vocabulary (CreateEntityHandlerBase.cs:148-151).
    • LogSponsorCreated (:26-27) is the source-generated partial with [LoggerMessage(Level = LogLevel.Information, Message = "Sponsor {SponsorId} created with name '{Name}'")]. That is why the class is partial: the generator supplies the body, and the template's {SponsorId} and {Name} become structured fields rather than a formatted string. [Rubric §13, Observability and Operability] assesses whether logs are queryable: the created id is a field, not text inside a message (ADR-041).
    • The inherited HandleAsync (CreateEntityHandlerBase.cs:56-63) null-guards the command and calls CreateCoreAsync (:76-102), which runs: PrepareAsync (:83, a pass-through by default, :113-117), requestMapper.CreateEntityAsync with an early exit on a domain failure (:89-91), attemptUnitOfWork.GetRepository<Sponsor, SponsorIdentifierType>() (:94), PersistAsync (:96, which is AddAsync then SaveChangesAsync, :137-138), then LogCreated (:98), OnCreatedAsync (:99, a no-op here), and finally Result.Success(dtoMapper.MapToDTO(entity)) (:101) so the caller receives the store-assigned identity in the response body.
    • Notice what is still absent from the whole path: no validation call, no transaction scope, no cache eviction, and no try/catch. Validation is applied by ValidatingCommandDecorator<TCommand, TResult> resolving SponsorCreateRequestValidator; invalidation is applied by CachingCommandDecorator<TCommand, TResult> reading CachePrefix off the request; the decorator ordering is registered at MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:131-137. [Rubric §12, Performance & Scalability]: every concern that would otherwise be copy-pasted into thirty handlers lives in a decorator.
  • Why it's built this way: the repository is resolved from the unit of work inside the workflow rather than constructor-injected, and the base states the rule outright (CreateEntityHandlerBase.cs:25-28): only the unit of work knows which physical data source the entity resolves to. Note also the injection asymmetry, the request mapper as an interface and the DTO mapper as a concrete class (CreateSponsorHandler.cs:17-18). That is a DI fact rather than a style choice: the framework scan registers both mapper families AsSelfWithInterfaces (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:200-204, :213-217), so either shape resolves.
  • Where it's used: registered by the ICommandHandler<,> assembly scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:240-244, invoked from MMCA.ADC.Conference.Application/DependencyInjection.cs:133) and injected into SponsorsController (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:42), reached through the base CreateAsync that the controller's permission-gated override wraps (SponsorsController.cs:162-171). The generic CRUD registration for sponsors runs after the scan and uses TryAdd, so this class keeps the create verb while the framework supplies update and delete (MMCA.ADC.Conference.Application/DependencyInjection.cs:135-145). Tests: CreateSponsorHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/UseCases/CreateSponsorHandlerTests.cs:14).
  • Caveats / not-in-source: nothing in the Application layer proves that EventId names a real event. SponsorCreateRequestValidator only requires it to be non-default through SponsorEventIdRules<T> (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:115-120), the mapper performs no lookup, and Sponsor.Create validates only name, logo URL, and booth number (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:119-122). The guarantee comes one layer down: the EF configuration declares a required FK to Event (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sponsors/SponsorConfiguration.cs:59-65), so a bogus id fails at SaveChangesAsync as a database error rather than as a validation Result.

PublicConferenceVisibility

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Common · MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:28 · Level 10 · class (static)

  • What it is: the single definition of what an anonymous or non-privileged caller may see in the conference catalog, expressed as three id-list resolvers that the public read filters turn into IN (...) specifications (MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:10-15). Three rules: an event is visible when published (BR-108), a session is visible when its event is visible and its status is on the BR-49 allow-list, and a speaker is visible when they have at least one eligible session inside the scoped published-event set (BR-239) (:18-21).
  • Depends on: IUnitOfWork and the read-side IEntityQuerier<TEntity, TIdentifierType> it hands out (:40, :75, :126-127, :151), CrossSourceSpecification (:63), InlineSpecification<TEntity, TIdentifierType> (:149), PublicSessionStatusSpecification (:68, :148), and the Event, Session, and SessionSpeaker entities.
  • Concept introduced, authorization as a translatable data filter: read authorization here is not a check that runs after the query, it is the query. Every rule is resolved into a materialized list of ids and embedded in a predicate, never expressed as a navigation join. The file states the three reasons (:22-26): the criteria stay translatable on any engine (ADR-018), each aggregate keeps its by-id boundary to the others, and the results pass the specification fitness test. [Rubric §11, Security] assesses whether authorization is enforced where the data is read rather than in a view: because one helper backs the session, speaker, sponsor, room, activity, and junction filters, closing a leak in one place closes it everywhere, which is the property that motivates the whole file. [Rubric §8, Data Architecture]: a cross-aggregate rule becomes a scalar projection plus an IN, the shape a split topology can still execute.
  • Concept reinforced, read repository versus write repository: every resolver here asks the unit of work for GetReadRepository<TEntity, TIdentifierType>() (:40, :75, :126-127, :151) and types the result as IEntityQuerier<TEntity, TIdentifierType>, the query-only face of the repository. That is the correct call for a projection nothing intends to mutate, and it is a different method from the GetRepository the write handlers in this group use. [Rubric §6, CQRS and Event-Driven] assesses whether the read and write paths are actually distinct at the abstraction level, not just by convention.
  • Walkthrough: three public resolvers and one private helper.
    • GetPublishedEventIdsAsync (:36-48) resolves the read repository (:40) and projects ids with a predicate in one call, GetProjectedAsync(e => e.Id, e => e.IsPublished, asTracking: false, ...) (:42-44). The result is materialized once so callers embed a stable collection EF can translate (:46-47).
    • GetVisibleSessionIdsAsync (:57-82) delegates the two-source AND to CrossSourceSpecification.BuildAsync (:63-70), passing e => e.IsPublished as the principal predicate, s => s.EventId as the dependent FK, and PublicSessionStatusSpecification.StatusCriteria as the local predicate. That helper runs the principal projection first and returns a specification whose criteria is localPredicate AND principalKeys.Contains(fk), built as an expression tree with no Expression.Invoke so it stays translatable on every provider (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:66-90). The specification then reaches the repository as a specification: ListAsync(specification, s => s.Id, cancellationToken) (:77-79) is the untracked, soft-delete-filtered projection that an explicit argument list used to spell out (:72-74).
    • GetVisibleSpeakerIdsAsync (:104-134) takes an optional event scope. It resolves the published set first (:109), and when a scope is supplied it narrows to that single event only if the event is published, otherwise to the empty list (:113-117); an unpublished or unknown scoped event is not an error, it simply has no public speakers (:111-112). Empty scope and empty eligible-session set both short-circuit to [] (:119-124), then the join table is projected with eligibleSessionIds.Contains(ss.SessionId) and de-duplicated (:126-133).
    • GetEligibleSessionIdsAsync (:141-158) is the private narrowing variant: new PublicSessionStatusSpecification().And(new InlineSpecification<Session, SessionIdentifierType>(s => scopedEventIds.Contains(s.EventId))) (:148-149), which keeps the criteria a translatable IN filter with no navigation join (:146-147).
  • Why it's built this way: the remark at :97-103 is the most load-bearing comment in the file, and it records a real rule, not a preference. The EventSpeaker junction is deliberately not treated as a visibility grant, because the Sessionize import writes a row there for every speaker in the response, so reading it as acceptance would publish the entire imported roster and make the filter vacuous. The session link is the only acceptance signal a speaker carries, so it is the only path consulted: a speaker whose sessions are all waitlisted or declined, and one linked to nothing, both stay hidden. [Rubric §4, DDD]: the rule is stated in the vocabulary of the business (published, accepted, assigned) and lives beside the aggregates it constrains.
  • Where it's used: by the eight public-filter query handlers, one per publicly readable entity or junction: GetPublicSponsorFilterHandler (MMCA.ADC.Conference.Application/Sponsors/UseCases/GetPublicSponsorFilter/GetPublicSponsorFilterHandler.cs:25), GetPublicActivityFilterHandler (.../Activities/UseCases/GetPublicActivityFilter/GetPublicActivityFilterHandler.cs:25), GetPublicRoomFilterHandler (.../Events/UseCases/GetPublicRoomFilter/GetPublicRoomFilterHandler.cs:25), GetPublicSpeakerFilterHandler (.../Speakers/UseCases/GetPublicSpeakerFilter/GetPublicSpeakerFilterHandler.cs:26, which passes the query's optional event scope straight through), GetPublicSpeakerCategoryItemFilterHandler (.../Speakers/UseCases/GetPublicSpeakerCategoryItemFilter/GetPublicSpeakerCategoryItemFilterHandler.cs:26), GetPublicSessionSpeakerFilterHandler (.../Sessions/UseCases/GetPublicSessionSpeakerFilter/GetPublicSessionSpeakerFilterHandler.cs:24), GetPublicSessionCategoryItemFilterHandler (.../Sessions/UseCases/GetPublicSessionCategoryItemFilter/GetPublicSessionCategoryItemFilterHandler.cs:25), and GetPublicEventSpeakerFilterHandler (.../Events/UseCases/GetPublicEventSpeakerFilter/GetPublicEventSpeakerFilterHandler.cs:31, :38), which calls two resolvers so a junction row follows the visibility of both its parents.
  • Caveats / not-in-source: the id lists are materialized and embedded, and the framework says so: the helper fits principal sets that are small and bounded, the "published events" shape (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:17-20). A single call to GetVisibleSpeakerIdsAsync issues three sequential round trips (events, eligible sessions, join rows), and the junction handler calls two resolvers, reading the bounded Event table twice. Nothing in this file caches any of it, and there is no test class dedicated to this type: its behavior is exercised only through the eight handlers that call it.

RemoveSessionQuestionAnswerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerHandler.cs:21 · Level 10 · class (sealed, partial)

  • What it is: the handler behind DELETE /SessionQuestionAnswers/{id}. It contributes four overrides to the framework's shared load-mutate-save workflow, and one of them earns a careful read: an ownership check. BR-52 and BR-53 say an attendee may delete only their own answers, while an organizer may delete any (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerHandler.cs:11-14).
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> closed over RemoveSessionQuestionAnswerCommand / Session / SessionIdentifierType (:24); IUnitOfWork, forwarded to the base (:21, :24); ICurrentUserService (:22); ILogger<RemoveSessionQuestionAnswerHandler> (:23); RoleNames for the Organizer constant (:71); IRepository<TEntity, TIdentifierType>, which the base passes into the load hook (:34); the SessionQuestionAnswer child; and Result / Error.
  • Concept introduced, the load-mutate-save workflow as a base class: this handler declares no HandleAsync of its own. MutateEntityHandlerCore<TCommand, TEntity, TIdentifierType> owns the sequence (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:271-309): resolve the repository (:279), load (:280), return Error.NotFound stamped with the handler name and the entity name when the aggregate is gone (:281-282), stamp the caller's RowVersion when the endpoint is conditional (:290-291, ADR-035), run the mutation (:293), short-circuit on failure (:294-295), honor a SkipSave no-op (:299-300), save (:302), then log (:304) and run the post-commit hook (:305). The MutateEntityHandlerBase layer on top adds only the return shape, a bare Result (:319-332). A module handler therefore supplies vocabulary, not plumbing. [Rubric §1, SOLID] assesses whether the varying part is the only part a subclass writes; [Rubric §15, Best Practices & Code Quality]: the concurrency stamp and the save-only-on-success rule were fixed once for every write handler in the workspace.
  • Concept introduced, row-level authorization inside the mutation step: permission attributes on a controller answer "may this caller call this endpoint"; they cannot answer "may this caller touch this row". That second question needs the row, so it is asked in MutateAsync, after the load. The class remark states the placement rule (:14-19): the gate runs inside the mutation step, which is where a refused invariant short-circuits before the save. The check is a single condition (:71): if the answer exists, the caller is not in the Organizer role, and the answer's CreatedBy differs from the current user id, the handler returns Error.Forbidden with the code SessionQuestionAnswer.NotOwner and a caller-safe message (:73-78). Ownership comes from the audit stamp the framework writes on insert, not from anything the client sent. [Rubric §11, Security] assesses whether authorization decisions are made where the data is, with identity taken from the token rather than the payload; both hold here.
  • Walkthrough: four overrides.
    • Includes => [nameof(Session.SessionQuestionAnswers)] (:27) declares the one navigation the mutation reads. AsTracking is left at the base default of true (MutateEntityHandlerBase.cs:76), which is load-bearing: a no-tracking load would turn the removal into a silent no-op.
    • EntityId(command) => command.SessionId (:30) is the by-id key the default load would use.
    • LoadAsync (:33-58) overrides that default because the endpoint's session id is optional. When command.SessionId == default (:44) the handler resolves the owning session from the join id with FirstOrDefaultAsync(s => s.SessionQuestionAnswers.Any(a => a.Id == command.SessionQuestionAnswerId), ...) (:46-50); otherwise it loads directly by id (:53-57). Both branches pass the same Includes and AsTracking, so the ownership gate below runs against a fully hydrated aggregate either way (:40-43).
    • MutateAsync (:61-81) finds the active answer in the loaded collection (:70, excluding soft-deleted rows), runs the ownership condition (:71-78), and delegates to entity.RemoveSessionQuestionAnswer(command.SessionQuestionAnswerId) (:80). The aggregate method soft-deletes the child through the shared RemoveChildOrNotFound helper and raises SessionQuestionAnswerChanged with DomainEntityState.Deleted (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:531-543).
    • LogMutated (:84-85) forwards to the [LoggerMessage] partial declared at :87-88, which is why the class is partial.
  • Why it's built this way: putting the ownership rule in the aggregate would force the domain to know about the current user, which is an application-layer concern; putting it in the controller would require loading the row twice. MutateAsync is the one place that already has both the identity service and the loaded answer. [Rubric §3, Clean Architecture].
  • Where it's used: registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into SessionQuestionAnswersController as removeHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:81) and called at SessionQuestionAnswersController.cs:234-236. Covered by RemoveSessionQuestionAnswerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerHandlerTests.cs:14).
  • Caveats / not-in-source: two things. First, the condition dereferences currentUserService.UserId!.Value (:71), so an unauthenticated caller reaching this handler would throw rather than be refused. That cannot happen through the REST surface, because the controller requires an authenticated principal for its entire surface (SessionQuestionAnswersController.cs:75), but the guarantee lives in the controller attribute, not in this file. Second, the branch is skipped entirely when answer is null (:71): a non-existent or already-deleted id falls through to the aggregate, which returns its own not-found rather than a forbidden, so the endpoint does not leak whether an answer the caller cannot see exists. Note also that this handler derives from MutateEntityHandlerBase and not from the narrower RemoveChildEntityHandlerBase its speaker sibling uses; both reach the same workflow, and nothing in source explains the difference.

SponsorCreateRequestMapper

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:11 · Level 10 · class (sealed)

  • What it is: the one place that knows how to turn a SponsorCreateRequest into a Sponsor. It does not construct the entity itself; it calls the domain factory and hands back whatever Result<Sponsor> that factory returns (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:19-31).
  • Depends on: IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType> closed over Sponsor / SponsorCreateRequest / SponsorIdentifierType (:12), the Sponsor aggregate and its Create factory, and Result (:3).
  • Concept reinforced, request mapping is not object mapping: SponsorDTOMapper can be source-generated because its target is a settable record. This mapper cannot, because its target is a factory that returns a Result<T>: the entity's constructor is private and the only way in runs the invariants first. So the direction out of the domain is generated and the direction into it is hand-written, which is exactly the split ADR-001 describes. [Rubric §4, DDD] assesses whether invariants are unavoidable: there is no path from a request to a Sponsor that skips Sponsor.Create.
  • Walkthrough: one method.
    • ArgumentNullException.ThrowIfNull(request) (:17) guards the reference the interface does not declare as nullable.
    • Task.FromResult(Sponsor.Create(...)) (:19-31) forwards twelve values in the factory's parameter order (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:105-117). There is no await because there is no I/O: the method is Task-returning to satisfy the interface, not because anything is asynchronous, and wrapping a completed value avoids allocating a state machine.
    • The work then happens in the domain: Sponsor.Create (Sponsor.cs:105-136) combines three invariant checks for name, logo URL, and booth number (:119-122), decides whether to honor or discard the supplied id based on IsIdValueGenerated (:126-131), and raises SponsorChanged with DomainEntityState.Added before returning success (:133).
    • request.Id is a non-nullable int widened to the factory's SponsorIdentifierType? parameter (Sponsor.cs:106), which is why the request can declare a plain value type and still reach a nullable factory slot.
  • Why it's built this way: keeping the factory call behind an interface means the create slice can grow an asynchronous pre-check (a uniqueness lookup, for example) by changing this one class, with no edit to CreateSponsorHandler, which forwards the interface into the framework base (CreateSponsorHandler.cs:17, :21). [Rubric §1, SOLID]: dependency inversion applied at the smallest useful granularity.
  • Where it's used: registered by the IEntityRequestMapper<,,> assembly scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:215-219, invoked from MMCA.ADC.Conference.Application/DependencyInjection.cs:133), forwarded from CreateSponsorHandler into the base, and called there at MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:90.
  • Caveats / not-in-source: the async signature is currently unused, and no existence or uniqueness check happens here today; see the caveat on CreateSponsorHandler for what does and does not verify EventId. The base's PrepareAsync hook (CreateEntityHandlerBase.cs:114-118) is the other place such a check could live, and the sponsor slice overrides neither.

SponsorCreateRequestValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors.UseCases.Create · MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:7 · Level 10 · class (sealed)

  • What it is: the FluentValidation validator for SponsorCreateRequest. Its constructor is two Include calls and nothing else (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:9-16): it owns no rule of its own, it composes the shared sponsor rule bundle and adds the one rule the create verb needs on top of it.
  • Depends on: AbstractValidator<T> from FluentValidation (:7), SponsorFieldRules<T> closed over the create request (:11), and SponsorEventIdRules<T> (:15).
  • Concept reinforced, composable rule objects, one level up: the Include technique is introduced in Group 06; what this file shows is the technique applied twice. Include merges another validator's rules into this one, and SponsorFieldRules<T> is itself nothing but eight Include calls over the ISponsorFieldsRequest members (MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:139-154). Because the bundle is constrained to the interface (SponsorValidationRules.cs:141), the create and the update validator bind the same eight rules to two different request types without restating any of them. The payoff is visible in the sibling: SponsorUpdateRequestValidator is a single Include of that same bundle (MMCA.ADC.Conference.Application/Sponsors/UseCases/Update/SponsorUpdateRequestValidator.cs:9-10), so the create and update contracts cannot drift on max lengths or error codes. [Rubric §24, Forms, Validation, and UX Safety] assesses whether validation is stated once and enforced consistently: the rule text and error codes a client sees are identical on both verbs.
  • Walkthrough: two lines, and the bundle behind the first.
  • Why it's built this way: no handler calls this class. ValidatingCommandDecorator<TCommand, TResult> resolves it from the container and runs it before the handler, converting failures into a Result rather than an exception (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:133). [Rubric §6, CQRS & Event-Driven Design] and [Rubric §6, CQRS and Event-Driven] both point at the same design: validation is a pipeline stage, so a handler that forgets to validate cannot exist.
  • Where it's used: registered by AddValidatorsFromAssembly(moduleAssembly) inside the module scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:252, invoked at MMCA.ADC.Conference.Application/DependencyInjection.cs:133). Its tests walk the boundaries directly, including the exact-max-length pass and the null-optional-strings pass (SponsorCreateRequestValidatorTests, MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/Validation/SponsorCreateRequestValidatorTests.cs:8).
  • Caveats / not-in-source: two gaps are worth knowing. NotEmpty on an int rejects only the default value, which the framework rule documents as the deliberate "an id was never supplied" check (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:133-139), so it proves an event was chosen, not that the event exists (the database FK does that, MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sponsors/SponsorConfiguration.cs:59-65). And no rule here constrains Tier: an out-of-range enum value passes validation, and Sponsor.Create does not check it either (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:119-122).

UpdateSessionQuestionAnswerCommandValidator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommandValidator.cs:16 · Level 10 · class (sealed)

  • What it is: the FluentValidation validator for UpdateSessionQuestionAnswerCommand. One rule chain over one property, AnswerValue, checking that it is present and not longer than any answer type could legitimately be (MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerCommandValidator.cs:18-25).
  • Depends on: AbstractValidator<T> from FluentValidation (:1, :16) and QuestionInvariants for the length constant (:2, :23-24).
  • Concept introduced, splitting a business rule by what it can see: BR-124 constrains an answer's value, but only part of it is checkable from the command alone. The class remark draws the line explicitly (:10-15): the type-independent half lives here, because "non-empty" and "under the widest ceiling" need nothing but the string; the type-specific half (a Rating between 1 and 5, an Email that parses, the Text length rule this ceiling mirrors) needs the owning question's type, which is a database read, so it stays in QuestionInvariants.EnsureAnswerValueMatchesQuestionType (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:118) where the handler has the entity in hand. [Rubric §24, Forms, Validation, and UX Safety] assesses whether a caller gets the cheapest correct rejection: a blank or 5000-character answer fails in the pipeline before a database round trip, while a "4" against a 1-to-3 rating question needs the question and fails in the domain. [Rubric §3, Clean Architecture]: neither half is duplicated in the other layer.
  • Walkthrough: one RuleFor chain (:19-25).
    • NotEmpty() with the message "Answer value is required." and the error code SessionQuestionAnswer.AnswerValue.Required (:20-22).
    • MaximumLength(QuestionInvariants.TextAnswerMaxLength) with the code SessionQuestionAnswer.AnswerValue.TooLong (:23-25). The constant is 2000 (QuestionInvariants.cs:28), and referencing it rather than a literal is what keeps this ceiling identical to the one the domain enforces for text answers (QuestionInvariants.cs:147-151).
    • Both failures carry an explicit WithErrorCode, so the API surfaces a stable machine-readable code rather than only English prose.
  • Why it's built this way: the file names its own model, AddSessionQuestionAnswerCommandValidator on the create side of the same aggregate (:7-8). Create and update of one child answer to the same rule, so the two validators are deliberately the same shape; a reader who has seen one has seen both.
  • Where it's used: nothing references it by name. It is discovered by AddValidatorsFromAssembly(moduleAssembly) in the module scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:252, invoked at MMCA.ADC.Conference.Application/DependencyInjection.cs:133) and run by ValidatingCommandDecorator<TCommand, TResult> ahead of UpdateSessionQuestionAnswerHandler.
  • Caveats / not-in-source: there is no test class for this validator in the Conference application test project, so the two error codes are pinned only by the source. Nothing here validates SessionId or SessionQuestionAnswerId; a zero or unknown id is left to the handler's load, which answers NotFound.

UpdateSessionQuestionAnswerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.UpdateSessionQuestionAnswer · MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:21 · Level 10 · class (sealed, partial)

  • What it is: the handler for UpdateSessionQuestionAnswerCommand. It supplies three overrides to the shared workflow: which navigation to load, which key to load by, and the mutation itself, which enforces the BR-52/BR-53 ownership rule before delegating to the aggregate (MMCA.ADC.Conference.Application/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandler.cs:21-61).
  • Depends on: MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> closed over the command, Session, and SessionIdentifierType (:24); IUnitOfWork (:21); ICurrentUserService (:22); ILogger<UpdateSessionQuestionAnswerHandler> (:23); RoleNames (:43); and Result / Error.
  • Concept reinforced, ownership enforced in the mutation step: the mechanism is taught on RemoveSessionQuestionAnswerHandler, and this class is its exact twin. The controller has already established that the caller is authenticated (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:75), but only the handler can establish whether this particular row is theirs, because that fact lives in the loaded entity's audit column. An Organizer may edit any answer, everyone else may edit only rows whose CreatedBy matches their user id (:42-50). [Rubric §11, Security] assesses whether authorization decisions are made where the necessary facts exist: role membership comes from the token, row ownership from the aggregate, and both are compared in one expression. [Rubric §4, DDD]: the check reads the child through the root's collection, never through a separate repository.
  • Walkthrough: three overrides plus the log method.
    • Includes => [nameof(Session.SessionQuestionAnswers)] (:27) and the inherited AsTracking default of true (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:76). Tracking is load-bearing: the mutation happens on this graph and SaveChangesAsync persists it only because the change tracker is watching.
    • EntityId(command) => command.SessionId (:30). Unlike its remove siblings this handler does not override LoadAsync, so it uses the base's plain by-id load (MutateEntityHandlerBase.cs:152-160). It can, because the PUT body always carries the session id (SessionQuestionAnswersController.cs:219), so there is no "id omitted" case to recover from. A missing session becomes Error.NotFound in the base, stamped with the handler name and the entity name (MutateEntityHandlerBase.cs:282-283).
    • MutateAsync (:33-53) null-guards both arguments (:38-39), finds the active answer with a.Id == command.SessionQuestionAnswerId && !a.IsDeleted (:42), and fails with Error.Forbidden(code: "SessionQuestionAnswer.NotOwner", ...) when the answer exists, the caller is not in RoleNames.Organizer, and answer.CreatedBy differs from the current user id (:43-50). The answer is not null guard is the interesting part: when the id names nothing, or names a soft-deleted row, the check is skipped and the call falls through to the domain, which resolves the child with the same active-only predicate and returns NotFound (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:508-511, into MMCA.Common/Source/Core/MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:103). The practical effect is that a non-owner probing for an id they cannot see receives NotFound rather than Forbidden, so the error does not confirm the row exists.
    • entity.UpdateSessionQuestionAnswer(command.SessionQuestionAnswerId, command.AnswerValue) (:52) does the real work: resolve the child, call answer.UpdateAnswer (which validates the text before assigning it, Session.cs:517-519), and raise SessionQuestionAnswerChanged with DomainEntityState.Updated (Session.cs:521). A failure returns before the save, which the base guarantees (MutateEntityHandlerBase.cs:295-296); that is safe precisely because the domain validates before it mutates, so a refused update leaves the tracked graph unchanged.
    • LogMutated (:56-57) forwards to the [LoggerMessage] partial at :59-60, which is why the class is partial.
  • Why it's built this way: the handler mirrors its sibling UpdateEventQuestionAnswerHandler, which is deliberate: session-level and event-level questionnaires answer to the same two business rules, and reading either one teaches both. The read side enforces the complementary rule with a specification instead of a branch: the controller scopes non-Organizer list reads with OwnedByUserSpecification<TEntity, TIdentifierType> (SessionQuestionAnswersController.cs:97), so ownership shows up as a filter on reads and as a guard on writes.
  • Where it's used: registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into SessionQuestionAnswersController as updateHandler (SessionQuestionAnswersController.cs:80) and called at :218-220. Covered by UpdateSessionQuestionAnswerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandlerTests.cs:14).
  • Caveats / not-in-source: the handler declares no RowVersion override, so the base's concurrency stamp is skipped (MutateEntityHandlerBase.cs:91, :290-291) and this update is unconditional: two attendees editing the same answer are last-write-wins. That is consistent with the endpoint, which states no If-Match precondition, but nothing in this file records the decision.

RemoveSessionSpeakerHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sessions.UseCases.RemoveSessionSpeaker · MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandler.cs:13 · Level 11 · class (sealed, partial)

  • What it is: the speaker-detach handler, and the member of the family that has to cope with a caller who does not know the parent id. Four overrides, no HandleAsync.
  • Depends on: RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType> closed over RemoveSessionSpeakerCommand / Session / SessionIdentifierType (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandler.cs:16); IUnitOfWork forwarded to the base (:14, :16); ILogger<RemoveSessionSpeakerHandler> (:15); IRepository<TEntity, TIdentifierType> as the load hook's parameter (:26); the SessionSpeaker child; and Result.
  • Concept introduced, the remove-a-child specialization: RemoveChildEntityHandlerBase is a thin layer over MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType> that changes exactly one thing: it re-declares Includes as protected abstract override (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:152). On the general base Includes is virtual and defaults to empty; here the compiler refuses to let a subclass omit it. The reason is stated at ChildEntityHandlerBase.cs:130-133: a remove that cannot see the child collection cannot find the child and reports a wrong NotFound. [Rubric §15, Best Practices and Code Quality] assesses whether a known failure mode is prevented at compile time rather than documented; making one hook abstract is the whole mechanism.
  • Concept introduced, resolving an aggregate root from a child id: the DELETE endpoint takes the session id as an optional query parameter, so a caller that omits it model-binds SessionId to the default 0 without a 400 (RemoveSessionSpeakerHandler.cs:32-34). The handler branches on that (:35): when the session id is unset it calls FirstOrDefaultAsync(s => s.SessionSpeakers.Any(ss => ss.Id == command.SessionSpeakerId), ...) with the same includes and tracking flag (:37-41); otherwise it loads directly by id (:44-48). Either way the rest of the workflow is identical, so the aggregate boundary is preserved: the removal is still performed by the root, never by reaching into a child repository. [Rubric §4, DDD] assesses exactly this, that children are mutated through their root. [Rubric §9, API and Contract Design]: the optional query parameter is what makes the two shapes one endpoint rather than two.
  • Walkthrough: four overrides.
    • Includes => [nameof(Session.SessionSpeakers)] (:19), the one the base makes mandatory.
    • EntityId(command) => command.SessionId (:22), used by the direct branch.
    • LoadAsync (:25-49), the branch described above, with ArgumentNullException.ThrowIfNull(repository) first (:30).
    • MutateAsync (:52-56) is a single expression: Task.FromResult(entity.RemoveSessionSpeaker(command.SessionSpeakerId)). The aggregate method soft-deletes the join row through the shared RemoveChildOrNotFound helper (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:373-374) and raises SessionSpeakerChanged with DomainEntityState.Deleted (Session.cs:379).
    • LogMutated (:59-60) forwards to the [LoggerMessage] partial at :62-63. Everything else, the repository resolution, the NotFound, the save-only-on-success rule, comes from MutateEntityHandlerCore.MutateCoreAsync (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:271-309).
  • Why it's built this way: the fallback exists because the UI reuses one generic delete affordance across every entity, and that component knows only the row's own id. Teaching the server to resolve the parent is cheaper than special-casing the client, and it keeps the endpoint usable by callers that do have the session id. Putting the fallback in LoadAsync rather than in HandleAsync means it is the only thing that varies: the concurrency stamp, the failure short-circuit, and the post-save hooks stay the framework's.
  • Where it's used: registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected into SessionSpeakersController as removeHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSpeakersController.cs:51) and called at SessionSpeakersController.cs:194-196. Covered by RemoveSessionSpeakerHandlerTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandlerTests.cs:12).
  • Caveats / not-in-source: the fallback query calls FirstOrDefaultAsync with a predicate over the child collection (:37-41). In practice a join id belongs to exactly one session, but the query does not say so. The log statement also records command.SessionId (:60), which is 0 on the fallback path, so the emitted event names the join row correctly and the session as zero.

DeleteEventHandler

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events.UseCases.Delete · MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:18 · Level 10 · class (sealed partial)

  • What it is: the module's replacement for the framework's generic delete handler on one entity, Event. Deleting an event has to reach three other aggregates (sessions, sponsors, activities) that the generic handler cannot see, so Conference registers this handler under the same contract and takes the delete slot over (MMCA.ADC.Conference.Application/Events/UseCases/Delete/DeleteEventHandler.cs:13-17).
  • Depends on: ICommandHandler<in TCommand, TResult> closed over DeleteEntityCommand<TEntity, TIdentifierType> for Event and Result (:21); IUnitOfWork (:19); IEventCascadeDeletionDomainService (:20); ILogger<DeleteEventHandler> from Microsoft.Extensions.Logging (:21); and the four aggregates Event, Session, Sponsor, and Activity.
  • Concept introduced, a cross-aggregate cascade split between the application and domain layers: an aggregate may delete everything it owns, and nothing else. Event.Delete() cascades to the children the event owns outright, its rooms, event speakers, and event question answers (BR-72, MMCA.ADC.Conference.Domain/Events/Event.cs:355-364), and stops there. Sessions, sponsors, and activities are separate aggregate roots that merely carry an EventId, so nothing inside Event can reach them. The pattern this file demonstrates splits the job in two: the application layer owns loading the other aggregates, because loading needs repositories, and the domain layer owns deciding and ordering the deletions, because that is a business rule. [Rubric §4, Domain-Driven Design] assesses whether aggregate boundaries are respected as consistency boundaries rather than smeared into one graph; here the boundary is respected literally, and the cross-boundary rule is stated once in a pure domain service (EventCascadeDeletionDomainService) with no infrastructure dependency (MMCA.ADC.Conference.Domain/Events/EventCascadeDeletionDomainService.cs:15). [Rubric §1, SOLID]: the generic handler stays closed for modification and this class is the extension, registered against the same interface. [Rubric §8, Data Architecture]: because everything is soft-delete, the whole cascade is a set of in-memory flag mutations followed by one write, not four delete statements.
  • Walkthrough: one method, and its shape is load-load-load-load, decide, save.
    • The primary constructor takes three services and declares the contract in the base list (:18-21). There is no IRepository parameter: every repository is pulled off IUnitOfWork inside the method, which is the framework's rule for keeping one tracked context per operation.
    • HandleAsync loads the event through unitOfWork.GetRepository<Event, EventIdentifierType>() with an explicit includes array naming Rooms, EventSpeakers, and EventQuestionAnswers, and with asTracking: true (:28-33). Tracking is the load-bearing argument in all four reads: the cascade mutates entities in memory and relies on the change tracker to turn those mutations into an UPDATE. An untracked graph would produce a silently successful no-op.
    • A missing event short-circuits with Error.NotFound stamped with source and target (:34-35), which is the Result idiom rather than an exception (ADR-013).
    • Sessions load next, with their own three child collections included so that each session's own cascade has its children in memory, filtered by s.EventId.Equals(entity.Id) && !s.IsDeleted and tracked (:38-43). Sponsors (:47-52) and activities (:56-61) follow the same shape with an empty includes array, because neither has children to cascade to.
    • eventCascadeDeletionDomainService.CascadeDelete(entity, sessions, sponsors, activities) (:64) hands all four sets to the domain service, which deletes sessions first (each cascading to its own children, BR-55, MMCA.ADC.Conference.Domain/Sessions/Session.cs:283-292), then sponsors, then activities, and only then the event (MMCA.ADC.Conference.Domain/Events/EventCascadeDeletionDomainService.cs:18-55). The first failure in any loop returns that failure unchanged and leaves the event untouched (EventCascadeDeletionDomainService.cs:31-32, :40-41, :50-51).
    • The save is conditional (:65-69): unitOfWork.SaveChangesAsync runs only when the cascade succeeded, so an aborted cascade's partial in-memory mutations are discarded with the scope rather than persisted. That single SaveChangesAsync is what makes the whole cascade atomic.
    • LogEventDeleted is a source-generated [LoggerMessage] partial at Information level carrying the event id (:74-75). [Rubric §13, Observability and Operability] assesses whether operationally interesting transitions are recorded in a structured, queryable form; the generator emits an allocation-free, strongly typed log call instead of an interpolated string, and it fires only on the success path.
  • Why it's built this way: the module is meant to be extractable as its own service (ADR-007, ADR-008), and the four aggregates here all live in the Conference database (ADR-006), so an in-process cascade over one unit of work is legitimate. The alternative, database-level ON DELETE CASCADE, is unavailable by construction: nothing is hard-deleted, rows are flagged (ADR-005), and a flag update is not something a foreign key can propagate. The comments name the consequence of getting this wrong: sponsors and activities left behind are rows the public sponsor strip and activities page keep reading (:45-46, :54-55).
  • Where it's used: registered as services.TryAddScoped<ICommandHandler<DeleteEntityCommand<Event, EventIdentifierType>, Result>, ...DeleteEventHandler>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:70), which is what makes it win the slot over the generic DeleteEntityHandler<TEntity, TIdentifierType> that Speaker and Question register (:75, :84). It is injected into EventsController as the delete handler (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:53) and invoked through the overridden DeleteAsync, which delegates to the base action and then evicts three output-cache tags, conference:events, conference:sessions, and conference:rooms, precisely because the cascade reached beyond the event (:368-375). Covered by DeleteEventHandlerTests, which asserts each of the three cascade legs separately (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/Delete/DeleteEventHandlerTests.cs:199, :222, :244) plus the not-found path and the save (:184, :266).
  • Caveats / not-in-source: the handler issues four reads before it writes anything, and three of them are unbounded by page size: an event with many sessions materializes all of them, with their children, into memory. Nothing in the file caps that. The !s.IsDeleted predicates (:41, :50, :59) are belt-and-braces on top of the global soft-delete query filter, so an already-deleted child is skipped rather than re-deleted. ConfigureAwait(false) appears on the save (:67) but not on the four repository awaits, an inconsistency with no visible effect in an ASP.NET Core host. The cache eviction lives in the controller, not here, so a caller invoking this handler by any other route deletes correctly but leaves the output cache stale.

EventLiveValidationService

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:24 · Level 10 · class (internal sealed)

  • What it is: Conference's answer to four questions the Engagement module's conference-day features have to ask before they will record anything: is this event published and when is it live, is this session eligible for the live layer and who speaks at it, does this sponsor exist and which event owns it, and which session is this room hosting right now. It is the implementation behind the cross-module contract IEventLiveValidationService (MMCA.ADC.Conference.Application/Events/EventLiveValidationService.cs:24). Note the accessibility: the class is internal, so nothing outside this assembly can name it, and every consumer sees only the interface.
  • Depends on: IUnitOfWork and the BCL TimeProvider (:22), the Event, Session, and Sponsor entities, SessionInvariants for the two eligibility rules (:67, :71), CurrentEventSelector for the live-window math (:227), CalendarExportMapper for wall-clock to UTC conversion (:194-195), and the four result records EventLiveInfo, SessionLiveInfo, SponsorLiveInfo, and RoomSessionInfo.
  • Concept introduced, the cross-module read contract: Engagement needs facts about conference data but must not reference Conference's entities, or the two modules could never be deployed apart. The pattern that solves it has three parts. The interface and its four record types live in MMCA.ADC.Conference.Shared (MMCA.ADC.Conference.Shared/Events/Live/IEventLiveValidationService.cs:13), a project both sides may reference. The implementation lives here, in Conference.Application, where the entities are. And the binding is swappable: in the modular monolith this class is registered (MMCA.ADC.Conference.Application/DependencyInjection.cs:129); in a split topology the Contracts package replaces it with a gRPC adapter that implements the same interface over the wire (EventLiveValidationServiceGrpcAdapter, MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/EventLiveValidationServiceGrpcAdapter.cs:27, swapped in with services.Replace(...) at MMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:79); and in a host that does not load Conference at all, the module registers DisabledEventLiveValidationService instead (MMCA.ADC.Conference.API/ConferenceModule.cs:24). Engagement's handlers see one interface in all three worlds. [Rubric §7, Microservices Readiness] assesses whether a module can be extracted without a rewrite: this is the extraction contract itself (ADR-007, ADR-008). [Rubric §9, API and Contract Design]: the DTO-like records carry only scalars and id lists, which is what keeps them serializable over gRPC unchanged.
  • Walkthrough: four public methods and one private helper.
    • GetEventLiveInfoAsync (:25-45) loads the event by id with no includes and asTracking: false (:30-34), returns Error.NotFound tagged with source and target when it is missing (:36-40), computes the window, and returns the published flag plus both boundaries (:42-44).
    • GetSessionLiveInfoAsync (:48-101) loads the session with its SessionSpeakers (:55), then applies the two bookmark-eligibility rules before anything else: EnsureNotServiceSession (BR-91, :67-69) and EnsureStatusIsEligible (BR-49, :71-73), both borrowed from the domain's own invariant helper so the live layer cannot drift from the bookmark rules. Only then does it fetch the owning event (:75-86), project the non-deleted speaker ids (:90-91), and return them with the plenum flag and the event's question-moderation default (:93-100).
    • GetSponsorLiveInfoAsync (:104-140) answers the printed-QR booth-visit lookup. The comment at :108-109 is the design note worth keeping: because the read repository applies the soft-delete filter, a pulled sponsor answers exactly like one that never existed, so a printed QR for a dropped sponsor simply stops working.
    • GetCurrentRoomSessionInfoAsync (:143-221) is the one with real logic. It loads every session in the room (:148-153), drops the ones with no schedule (:157-159), resolves the owning event from the first survivor (:167-172), resolves the event's IANA zone (:182), converts each session's wall-clock start and end into UTC through CalendarExportMapper.ToUtc (:189-197), and then picks: an in-progress session (StartsAtUtc <= now < EndsAtUtc) wins, and only if there is none does it accept the earliest session starting inside the grace window (:201-208). The comment at :199-200 explains why the order matters: back-to-back sessions overlap inside the grace window, and the attendee scanning the room QR is standing in the one that is actually running. The grace value is clamped at zero before use (:184), and the window scan is restricted to sessions of the resolved event (:190).
    • ComputeLiveWindowUtc (:226-230) delegates to CurrentEventSelector.GetLiveWindowUtc (MMCA.ADC.Conference.Shared/Events/CurrentEventSelector.cs:66-76) rather than repeating the rule. That shared helper defines the window as start date at 00:00 local through end date plus one day at 00:00 local (exclusive), and its ToUtc handles the spring-forward gap where local midnight never existed by shifting into the hour that did exist (CurrentEventSelector.cs:87-95). The comment at :223-225 states the reason plainly: the home surfaces, the now-next snapshot, and this service must agree on when an event is live, or two surfaces disagree in front of an audience.
  • Why it's built this way: TimeProvider is injected rather than DateTime.UtcNow being called, which is what makes the room-resolution rules testable at all: the suite drives them with FakeTimeProvider pinned to 2026-09-15 14:30 UTC (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventLiveValidationServiceTests.cs:26, :38). [Rubric §14, Testability] assesses whether behavior can be exercised deterministically: the suite exercises all four methods, including back-to-back sessions (:305), both grace-window boundaries (:279, :293), unscheduled sessions (:321), unknown rooms (:335), other rooms' sessions (:346), and unpublished events (:82, :248, :360). The grace window is a parameter, not a Conference setting, because it is check-in policy and Conference only answers the schedule question (MMCA.ADC.Conference.Shared/Events/Live/IEventLiveValidationService.cs:52-55).
  • Where it's used: registered as services.TryAddScoped<IEventLiveValidationService, EventLiveValidationService>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:129) and consumed exclusively by Engagement: the live-poll lifecycle handlers, the session-question submit and moderation handlers, and the check-in flows including CheckInProcessor (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Application/CheckIns/Services/CheckInProcessor.cs:111, :162), RecordRoomCheckInHandler (.../CheckIns/UseCases/RecordRoomCheckIn/RecordRoomCheckInHandler.cs:28), RecordSponsorVisitHandler (.../CheckIns/UseCases/RecordSponsorVisit/RecordSponsorVisitHandler.cs:36), SubmitQuestionHandler (.../SessionQuestions/UseCases/Submit/SubmitQuestionHandler.cs:27), and OpenLivePollHandler (.../LivePolls/UseCases/Open/OpenLivePollHandler.cs:22). In the split topology it is exposed over gRPC by EventLiveValidationGrpcService (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/EventLiveValidationGrpcService.cs:22).
  • Caveats / not-in-source: three edges.
    • A bad time-zone id throws. TimeZoneInfo.FindSystemTimeZoneById(conferenceEvent.TimeZone) is called with no try/catch and no fallback (:182), and the comment above it says that is deliberate: EventInvariants.EnsureTimeZoneIsValid guards every write path, so an unresolvable stored id is a data defect that must surface rather than degrade silently (:180-181). ComputeLiveWindowUtc inherits the same posture through the shared helper (CurrentEventSelector.cs:72).
    • The first two methods resolve the write-capable repository through unitOfWork.GetRepository<...>() (:29, :52, :75) while the sponsor and room methods use GetReadRepository<...>() (:110, :123, :148, :167). Every call passes asTracking: false, so the reads are untracked either way; the inconsistency is in which repository is asked for, not in what the query does.
    • GetCurrentRoomSessionInfoAsync loads all sessions for the room and filters in memory (:149-159, :189-197), because the wall-clock to UTC conversion is compiled code that cannot be translated to SQL. Room-sized session counts make that fine today; nothing in the code bounds it.

EventNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the navigation populator for the Event aggregate, the largest one in the module: three child-collection descriptors for Rooms, EventSpeakers, and EventQuestionAnswers (MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:14-36). The class body is empty (:37-38): everything it does is data passed to the base constructor.

  • Depends on: DeclarativeNavigationPopulator<TEntity> over Event (:13), three ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> instances (:15, :22, :29), IUnitOfWork forwarded straight to the base (:12-13), and the Event, Room, EventSpeaker, and EventQuestionAnswer entities.

  • Concept reinforced: child-collection binding is taught on ConferenceCategoryNavigationPopulator, and the populator pattern itself in Group 11. What this class adds is scale (three descriptors evaluated in declaration order, MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:34-41) and one genuinely instructive mismatch, in the caveat below.

  • Walkthrough: all three descriptors key on Event.Id against the child's EventId, and each supplies the same four settings.

    Descriptor File:Line Property, parent key, child FK, assign
    ChildNavigationDescriptor<Event, EventIdentifierType, Room, RoomIdentifierType> MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:15-21 nameof(Event.Rooms), e => e.Id, child => child.EventId, e.SetRooms(rooms)
    ChildNavigationDescriptor<Event, EventIdentifierType, EventSpeaker, EventSpeakerIdentifierType> MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:22-28 nameof(Event.EventSpeakers), e => e.Id, child => child.EventId, e.SetEventSpeakers(eventSpeakers)
    ChildNavigationDescriptor<Event, EventIdentifierType, EventQuestionAnswer, EventQuestionAnswerIdentifierType> MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:29-35 nameof(Event.EventQuestionAnswers), e => e.Id, child => child.EventId, e.SetEventQuestionAnswers(answers)
    • PropertyName is written with nameof in all three (:17, :24, :31) and that is not decoration. The base builds a HashSet<string> of the query's UnsupportedIncludes property names with an ordinal comparer and loads a descriptor only when its PropertyName is in that set (DeclarativeNavigationPopulator.cs:30-37), so a typo would be a silently unpopulated navigation rather than a compile error.
    • All three assign through internal aggregate mutators, SetRooms (MMCA.ADC.Conference.Domain/Events/Event.cs:525), SetEventSpeakers (:580), and SetEventQuestionAnswers (:654), each a thin delegation to the framework's SetItems over a private backing list. They are reachable from the Application project only because the Domain project grants <InternalsVisibleTo Include="MMCA.ADC.Conference.Application" /> (MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3). The collections themselves are exposed as IReadOnlyCollection<> over those private lists (Event.cs:89, :95, :109), so no other assembly can replace them. [Rubric §4, Domain-Driven Design]: hydration passes through the same door a business operation would, not a back-door property write.
    • Each descriptor's load is one batched WHERE EventId IN (...) query against the child's read repository (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:41-47, executing NavigationLoader.LoadChildrenPropertyAsync at MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:118), so a fully hydrated page of events costs three extra queries, not three per event.
    • All three are gated on the caller's includeChildren argument, because ChildNavigationDescriptor.RequiresChildren is true (ChildNavigationDescriptor.cs:25) and the base picks the gate per descriptor (DeclarativeNavigationPopulator.cs:36).
  • Why it's built this way: the entity type parameters are what make this survivable under extraction. Room and EventSpeaker may end up in a different physical source than Event, at which point .Include() stops being an option and only a batched key lookup can hydrate them (ADR-002, ADR-006). The classification is made per navigation by NavigationMetadataProvider, which asks whether the declaring and target entity types share include support and files the navigation as supported or unsupported accordingly (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:96-99). [Rubric §8, Data Architecture] assesses how relationships are expressed: a cross-source parent-child link degrades to a scalar FK plus an IN lookup, and that is precisely what these three declarations are.

  • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Event>, EventNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:68), beside the closed-generic read service for events (:66) and the module's own delete handler (:67). Covered by EventNavigationPopulatorTests, which pins the type to INavigationPopulator<Event> and asserts both empty-input short circuits (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventNavigationPopulatorTests.cs:16, :20, :24, :35).

  • Caveats / not-in-source: the third descriptor cannot fire through the populator path as the code stands. Event.EventQuestionAnswers is deliberately not marked [Navigation] (MMCA.ADC.Conference.Domain/Events/Event.cs:102-112 documents why: the collection grows with attendance rather than with the schedule, it rode along on public reads that never render it, and it is per-attendee feedback behind an anonymous endpoint). Navigation discovery is attribute-driven and returns early when the attribute is absent (NavigationMetadataProvider.cs:74-76), so the property never appears in UnsupportedIncludes, and the base's unsupportedPropertyNames.Contains(descriptor.PropertyName) test never matches it (DeclarativeNavigationPopulator.cs:37). Handlers that need those answers pass an explicit includes: list instead, which is exactly what the entity's own remark instructs. The descriptor is harmless and would become live again the moment the attribute returned.

EventQuestionAnswerNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/EventQuestionAnswerNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the FK populator for EventQuestionAnswer read as its own entity, hydrating each answer's parent Event reference (MMCA.ADC.Conference.Application/Events/EventQuestionAnswerNavigationPopulator.cs:7-10). Empty class body (:23-24).
  • Depends on: DeclarativeNavigationPopulator<TEntity> over EventQuestionAnswer (:13), one FKNavigationDescriptor<TEntity, TChild, TChildId> closed over EventQuestionAnswer / Event / EventIdentifierType (:15), and IUnitOfWork (:12).
  • Concept reinforced: mechanically identical to ActivityNavigationPopulator, which teaches the FK descriptor, the includeFKs gate, and the batched loader; SpeakerCategoryItemNavigationPopulator below walks the same four settings in detail.
  • Walkthrough: PropertyName = nameof(EventQuestionAnswer.Event) (:17), ParentKeySelector = e => e.EventId (:18, the get-only FK at MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:26), ChildForeignKeySelector = child => child.Id (:19), and AssignAction = (e, events) => e.SetEvent(events.FirstOrDefault()) (:20), which calls the entity's public mutator (EventQuestionAnswer.cs:84) rather than writing the property, because the navigation itself is [Navigation] public Event? Event { get; private set; } (EventQuestionAnswer.cs:22-23). FirstOrDefault() is there because the loader always hands back a List<TChild> and a reference navigation wants one element out of it (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:32).
  • Why it's built this way: note the asymmetry with the parent side. Event.EventQuestionAnswers carries no [Navigation] attribute, so the forward collection is not auto-hydrated (see the caveat on EventNavigationPopulator), while EventQuestionAnswer.Event is attributed, so a read that starts at the answer can still reach its event. The direction that is cheap and bounded is enabled; the direction that is unbounded is not. [Rubric §12, Performance and Scalability] is the reason the two directions are configured differently.
  • Where it's used: registered as INavigationPopulator<EventQuestionAnswer> (MMCA.ADC.Conference.Application/DependencyInjection.cs:106) alongside the closed-generic read service for the same entity (:104), which is injected into EventQuestionAnswersController (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:56). Covered by EventQuestionAnswerNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventQuestionAnswerNavigationPopulatorTests.cs:16, :20, :24, :35).

EventSpeakerNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/EventSpeakerNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the FK populator for the EventSpeaker junction, hydrating its parent Event reference (MMCA.ADC.Conference.Application/Events/EventSpeakerNavigationPopulator.cs:7-10). Empty class body (:23-24).
  • Depends on: DeclarativeNavigationPopulator<TEntity> over EventSpeaker (:13), one FKNavigationDescriptor<TEntity, TChild, TChildId> closed over EventSpeaker / Event / EventIdentifierType (:15), and IUnitOfWork (:12).
  • Concept reinforced: the FK mechanism is taught on ActivityNavigationPopulator; the four settings here are nameof(EventSpeaker.Event) (:17), e => e.EventId (:18, the get-only FK at MMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:24), child => child.Id (:19), and e.SetEvent(events.FirstOrDefault()) (:20) through the entity's public mutator (EventSpeaker.cs:61) into the private-set, attributed navigation (EventSpeaker.cs:20-21).
  • Why it's built this way: the junction carries only the two parent references, so hydrating the event side is the difference between a usable association read and a row of bare integers. Note that the speaker side is not declared here: EventSpeaker has no Speaker navigation descriptor in this file, which is consistent with speakers being reachable through their own aggregate.
  • Where it's used: registered as INavigationPopulator<EventSpeaker> (MMCA.ADC.Conference.Application/DependencyInjection.cs:103) beside its read service (:101), which is injected into EventSpeakersController (MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:48). Covered by EventSpeakerNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/EventSpeakerNavigationPopulatorTests.cs:16, :20, :24, :35).
  • Caveats / not-in-source: this junction is also the one the public-visibility rules refuse to trust as an acceptance signal, because the Sessionize import writes a row for every speaker in the response; see PublicConferenceVisibility. Hydration and visibility are separate concerns here, and this file only does the former.

RoomNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Events · MMCA.ADC.Conference.Application/Events/RoomNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the FK populator for Room, hydrating each room's parent Event reference (MMCA.ADC.Conference.Application/Events/RoomNavigationPopulator.cs:7-10). Empty class body (:23-24).
  • Depends on: DeclarativeNavigationPopulator<TEntity> over Room (:13), one FKNavigationDescriptor<TEntity, TChild, TChildId> closed over Room / Event / EventIdentifierType (:15), and IUnitOfWork (:12).
  • Concept reinforced: identical in mechanism to ActivityNavigationPopulator. The settings are nameof(Room.Event) (:17), e => e.EventId (:18, the get-only FK at MMCA.ADC.Conference.Domain/Events/Room.cs:38), child => child.Id (:19), and e.SetEvent(events.FirstOrDefault()) (:20) through the public mutator (Room.cs:145) into the private-set, [Navigation]-attributed property (Room.cs:34-35).
  • Why it's built this way: rooms are the one child of Event that is read from both ends in production. EventNavigationPopulator hydrates Event.Rooms for an event-first read, and this class hydrates Room.Event for a room-first read, both through the same base and both as batched key lookups (ADR-002). [Rubric §7, Microservices Readiness]: neither direction assumes the two entities share a database.
  • Where it's used: registered as INavigationPopulator<Room> (MMCA.ADC.Conference.Application/DependencyInjection.cs:97) beside the closed-generic read service for rooms (:95), which is injected into RoomsController (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:93). Covered by RoomNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/RoomNavigationPopulatorTests.cs:16, :20, :24, :35).

SpeakerCategoryItemNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers · MMCA.ADC.Conference.Application/Speakers/SpeakerCategoryItemNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the navigation populator for the SpeakerCategoryItem join entity when it is read as its own entity rather than as a child of a speaker. It declares exactly one navigation, the parent Speaker back-reference (MMCA.ADC.Conference.Application/Speakers/SpeakerCategoryItemNavigationPopulator.cs:7-10). The class body is empty (:23-24): everything it does is data passed to the base constructor.
  • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SpeakerCategoryItem (:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork, forwarded straight through (:12-13); and the Speaker aggregate as the reference target.
  • Concept introduced for the speaker slice, the FK direction of a declarative populator: Group 11 teaches the populator pattern itself; what this file shows is the reference direction, the mirror image of the collection direction SpeakerNavigationPopulator uses. An FKNavigationDescriptor reads the key off each parent row, drops nulls, distincts them, builds child => parentIds.Contains(child.Id) as an expression tree, runs one untracked query, groups the results by FK for O(1) lookup, and assigns each row its match (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:54-99). That is why the AssignAction ends in FirstOrDefault() (:20): the loader always hands back a List<TChild>, and a reference navigation wants one element out of it. The descriptor also declares RequiresChildren => false (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23), which is what lets a caller ask for FK references without paying for child collections: the base tests that flag against the caller's includeFKs / includeChildren arguments before loading anything (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:36-40). [Rubric §12, Performance and Scalability] assesses whether reads scale with page size rather than row count: one batched query per descriptor for the whole page, never one per row. [Rubric §2, Design Patterns]: Template Method configured by data instead of by overrides, which is why the body is genuinely empty.
  • Walkthrough: one descriptor, four settings, and a generic quartet worth reading closely.
    • PropertyName = nameof(SpeakerCategoryItem.Speaker) (:17) is not decoration. The base builds a set of the query's UnsupportedIncludes property names with an ordinal comparer and loads a descriptor only when its PropertyName is in that set (DeclarativeNavigationPopulator.cs:30-37), so a typo here is a silently unpopulated navigation, not a compile error. nameof is what prevents that.
    • ParentKeySelector = e => e.SpeakerId (:18) reads the join row's FK, a get-only property with no setter at all (MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:24), and ChildForeignKeySelector = child => child.Id (:19) names the target's primary key, because on the FK direction the "child" of the descriptor is the referenced parent entity.
    • AssignAction = (e, speakers) => e.SetSpeaker(speakers.FirstOrDefault()) (:20) calls the entity's own public mutator (SpeakerCategoryItem.cs:61), whose doc comment names the populator as its caller. The navigation itself is [Navigation] public Speaker? Speaker { get; private set; } (SpeakerCategoryItem.cs:20-21): the attribute is what puts this property in the FK bucket during metadata discovery, and the private setter plus the named mutator are what keep the write path explicit rather than open to anyone holding the entity. [Rubric §4, Domain-Driven Design]: hydration goes through a door the entity opened on purpose, not through a public setter.
    • The closed generic is FKNavigationDescriptor<SpeakerCategoryItem, Speaker, SpeakerIdentifierType> (:15). Speaker is the one Conference aggregate whose key is not an int: SpeakerIdentifierType aliases System.Guid because speakers carry Sessionize-assigned GUIDs (BR-61, MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3, :19). That satisfies the descriptor's where TChildId : struct constraint (FKNavigationDescriptor.cs:17), and the non-nullable SpeakerId widens to the TChildId? the ParentKeySelector declares (FKNavigationDescriptor.cs:26).
  • Why it's built this way: the classification that triggers any of this is made per navigation by NavigationMetadataProvider, which asks whether the declaring and target entity types share include support and files the navigation as supported or unsupported accordingly (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:96-99). Two entities in the same physical source stay on .Include(); two entities split across sources cannot be joined, and only a second batched query can hydrate the relationship (ADR-002, ADR-006, ADR-018). Since source assignment is configuration, this file is a no-op on a topology where the two entities live together and becomes the hydration path on one where they do not, with no change to the controller or the query service. [Rubric §3, Clean Architecture]: the Application layer states hydration as property names and key selectors, with no EF Core namespace anywhere in the file.
  • Where it's used: registered as services.TryAddScoped<INavigationPopulator<SpeakerCategoryItem>, SpeakerCategoryItemNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:118), directly above the closed generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registration for the same entity (:116), which is the pairing that puts it on every direct read of a speaker category item (ADR-034); that query service is injected into SpeakerCategoryItemsController (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:49). Covered by SpeakerCategoryItemNavigationPopulatorTests, which pins the type to INavigationPopulator<SpeakerCategoryItem> and asserts both empty-input short circuits (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerCategoryItemNavigationPopulatorTests.cs:16, :20, :24, :35).
  • Caveats / not-in-source: SpeakerCategoryItemDTO carries only Id, SpeakerId, and CategoryItemId (MMCA.ADC.Conference.Shared/Speakers/SpeakerCategoryItemDTO.cs:11-17), so nothing this populator hydrates reaches the API response on the read path today: the descriptor exists for the shape of the entity, not for a field a caller currently sees. The descriptor list also covers one side of the join only, Speaker and not CategoryItem, and the file gives no reason for the asymmetry.

SpeakerEntityQueryService

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers · MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:15 · Level 10 · class (sealed)

  • What it is: the only subclass of the framework's generic query service in the whole Conference module. It adds exactly one thing to EntityQueryService<TEntity, TEntityDTO, TIdentifierType>: a one-entry map that teaches the read pipeline how to filter and sort by FullName, a name that exists on the DTO and on the entity but in no database column (MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:11-14).
  • Depends on: EntityQueryService<TEntity, TEntityDTO, TIdentifierType> closed over Speaker / SpeakerDTO / SpeakerIdentifierType (:21-22), and the five collaborators it forwards to the base unchanged (:15-20): IUnitOfWork, INavigationMetadataProvider, IEntityQueryPipeline, SpeakerDTOMapper, and INavigationPopulator<in TEntity> closed over Speaker (satisfied at runtime by SpeakerNavigationPopulator).
  • Concept introduced, the DTO-to-entity property map: a REST client filters and sorts using the vocabulary of the DTO it received, but the query runs against the entity. Most names line up; FullName does not. On the entity it is a computed, get-only property, public string FullName => $"{FirstName} {LastName}" (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61), and the EF configuration explicitly removes it from the model with builder.Ignore(p => p.FullName) (MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerConfiguration.cs:68), so there is nothing named FullName for SQL to order by or match against. The map closes that gap by pairing the DTO name with a Dynamic LINQ expression over the two real columns rather than with a property path. [Rubric §12, Performance and Scalability] assesses whether reads stay translatable and server-side: the alternative to this one dictionary entry is fetching every speaker and matching the search box in memory, which a paged endpoint cannot do correctly. [Rubric §9, API and Contract Design] assesses whether the contract a caller sees is coherent: callers filter by the field they were served, and the translation stays a server concern. [Rubric §11, Security] is relevant too, and the framework is explicit about why: map entries are accepted unconditionally during field validation precisely because they are server-authored, never client-supplied (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:377-379), while any name the server never mapped still has to survive reflection against the entity (:381-393), so a client cannot inject an expression of its own.
  • Walkthrough: the whole class is thirty-five lines, and all of it is configuration.
    • The primary constructor takes the five services and forwards them positionally to the base (:15-22). Note that the mapper parameter is the concrete SpeakerDTOMapper, not the IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType> the base declares (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:32-37): the subclass names the implementation and lets the compiler widen it, which is how DI resolves the Mapperly-generated mapper by its own type.
    • PropertyMap is a private static readonly IReadOnlyDictionary<string, string> with one entry: [nameof(SpeakerDTO.FullName)] = "(FirstName + \" \" + LastName)" (:28-31). Using nameof keys the map to the DTO property (MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:67), so renaming it breaks the build instead of silently breaking a sort. The outer parentheses in the value are load-bearing, and the next bullet shows why.
    • DTOToEntityPropertyMap overrides the base's virtual, empty default and returns that static instance (:34). One allocation for the process, not one per request.
    • What the base does with it is the interesting half. The value flows into three places: validation of the sort column and of every filter (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:265, :266), and the EntityQueryParameters<TEntity> handed to the pipeline (:295, and :527 on the by-id path). On the filter path, QueryFilterService resolves the incoming key FullName through the map to the expression, resolves a PropertyInfo for the DTO-facing name so type resolution still works, and hands the expression to the string strategy (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:85-97, with the two-name lookup at :245-252). A CONTAINS there becomes query.Where("(FirstName + \" \" + LastName).Contains(@0)", value) (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/StringFilterStrategy.cs:23), a plain concatenation predicate EF Core translates to SQL over the two real columns. Drop the parentheses from the map value and the same template would read FirstName + " " + LastName.Contains(@0), a different expression entirely. On the sort path, QueryFieldService.ApplySorting resolves the column through the map before appending the direction and the server-supplied tie-break key, then calls Dynamic LINQ OrderBy (MMCA.Common/Source/Core/MMCA.Common.Application/Services/QueryFieldService.cs:155-169, :190-202).
    • Everything else this service can do is inherited untouched: parameter validation, the by-id fast path, pagination metadata, field shaping, and the navigation-population step that invokes NavigationPopulator.PopulateAsync as a delegate handed down to the pipeline (EntityQueryService.cs:321, :534).
  • Why it's built this way: the framework offers an override hook rather than a configuration file or an attribute, so the mapping lives in the module that owns the vocabulary and costs nothing for entities that need none. That is visible in the registration block: Speaker gets this subclass (MMCA.ADC.Conference.Application/DependencyInjection.cs:77) while Sponsor and SpeakerCategoryItem register the closed generic base directly (:91, :116). [Rubric §15, Best Practices & Code Quality]: the delta between "standard entity" and "entity with a computed sort field" is one dictionary entry.
  • Where it's used: registered as services.TryAddScoped<IEntityQueryService<Speaker, SpeakerDTO, SpeakerIdentifierType>, SpeakerEntityQueryService>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:77) and injected into SpeakersController as the interface (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:48). Both speaker grids drive it with exactly this vocabulary: the organizer list sends filters["FullName"] = ("contains", _searchString) and sorts by "FullName" (MMCA.ADC.Conference.UI/Pages/Speakers/SpeakerList.razor.cs:69, :79), and the public list does the same (MMCA.ADC.Conference.UI/Pages/Public/Speakers/PublicSpeakerList.razor.cs:227, :130). SpeakerEntityQueryServiceTests pins the contract, asserting that the captured pipeline parameters carry the FullName key mapped to the exact entity expression (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerEntityQueryServiceTests.cs:18, :89-104) and that an unmapped, unknown filter property fails validation before the pipeline is touched (:109).
  • Caveats / not-in-source: two edges are worth knowing.
    • FullName is filterable and sortable but not requestable as a shaped field. The fields parameter is validated through the overload that takes no map (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:264, resolving to QueryFieldService.cs:317-318), so ?fields=FullName never reaches the map and is rejected as a read-only property, which is consistent with server-side projection being restricted to writable properties (QueryFieldService.cs:395-400).
    • PropertyMap is built with the default (ordinal, case-sensitive) comparer (:28), while the base's empty default uses StringComparer.OrdinalIgnoreCase (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:101). Only the exact key FullName hits the map. A lowercase filter key misses it and is then rejected cleanly, because filter property lookup reflects case-sensitively (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Filtering/QueryFilterService.cs:263). A lowercase sort column behaves differently: sort validation matches property names case-insensitively (QueryFieldService.cs:381-382) and the unmapped fallback in ResolveSortExpression resolves with BindingFlags.IgnoreCase (:197-201), so the request passes validation and Dynamic LINQ receives the bare, EF-ignored FullName instead of the mapped expression. What the database layer does with that is not exercised anywhere in this repository: not determinable from source. Every caller in the codebase sends the exact-case key.

SpeakerNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers · MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the declarative navigation populator for the Speaker aggregate. It declares how to hydrate the two child collections the read path may not be able to materialize through .Include(), SpeakerCategoryItems and SpeakerQuestionAnswers (MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:7-10). Like its siblings, the class body is empty (:30-31).

  • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Speaker (:13); ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId> (:15, :22); IUnitOfWork, forwarded straight to the base (:12-13); and the Speaker, SpeakerCategoryItem, and SpeakerQuestionAnswer entities.

  • Concept reinforced, the collection direction: the mechanism is taught in Group 11 and, for the reference direction, by SpeakerCategoryItemNavigationPopulator above (ADR-002). A ChildNavigationDescriptor inverts the FK direction: it reads the parent's primary key, matches it against a foreign key the children hold, and assigns the whole list rather than one element (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:28-34). It also declares RequiresChildren => true (ChildNavigationDescriptor.cs:25), so both descriptors here are gated on the caller's includeChildren argument and stay dormant on a read that only asked for FK references. [Rubric §2, Design Patterns]: Template Method configured by data, so adding a child collection is a descriptor, not a new query method. [Rubric §3, Clean Architecture]: the Application layer states hydration as key selectors and property names, with no EF Core namespace in the file.

  • Walkthrough: two descriptors, both keyed on Speaker.Id against the child's SpeakerId, each supplying the same four settings.

    Descriptor File:Line Property, parent key, child FK, assign
    ChildNavigationDescriptor<Speaker, SpeakerIdentifierType, SpeakerCategoryItem, SpeakerCategoryItemIdentifierType> MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:15-21 nameof(Speaker.SpeakerCategoryItems), e => e.Id, child => child.SpeakerId, e.SetSpeakerCategoryItems(categoryItems)
    ChildNavigationDescriptor<Speaker, SpeakerIdentifierType, SpeakerQuestionAnswer, SpeakerQuestionAnswerIdentifierType> MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:22-28 nameof(Speaker.SpeakerQuestionAnswers), e => e.Id, child => child.SpeakerId, e.SetSpeakerQuestionAnswers(answers)
    • The generic quartet is worth reading closely, because the parent key and the child keys are different types here: TParentId is SpeakerIdentifierType (System.Guid) while both TChildId values are int (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:18-20). The descriptor keeps the two apart as separate type parameters (ChildNavigationDescriptor.cs:15-19), so the batch load compares GUID to GUID (child => child.SpeakerId is typed SpeakerIdentifierType on both children: MMCA.ADC.Conference.Domain/Speakers/SpeakerCategoryItem.cs:24, MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:26) while each child's own read repository is still resolved by its own int key (ChildNavigationDescriptor.cs:45).
    • Both AssignAction targets go through the aggregate's own mutators, SetSpeakerCategoryItems (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:389-390) and SetSpeakerQuestionAnswers (:463-464), each a thin delegation to the framework's SetItems over the private backing list. Both are internal, reachable from here only because the Domain project grants <InternalsVisibleTo Include="MMCA.ADC.Conference.Application" /> (MMCA.ADC.Conference.Domain/MMCA.ADC.Conference.Domain.csproj:3). The collections themselves are exposed as IReadOnlyCollection<> over private lists (Speaker.cs:66-73), so no other assembly can replace them. [Rubric §4, Domain-Driven Design]: hydration passes through the same door a business operation would, not a back-door property write.
    • The base owns the algorithm and its guards decide when any of this runs. PopulateAsync returns immediately when there are no entities or no unsupported includes, then loads only descriptors whose PropertyName appears in UnsupportedIncludes (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/DeclarativeNavigationPopulator.cs:27-41). Both names match [Navigation(IsCollection = true)] attributes on the aggregate (Speaker.cs:66, :72), which is what puts them in the child-collection bucket during metadata discovery (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:78-80). The load itself is one batched WHERE childFK IN (...parentIds) query per descriptor via NavigationLoader (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:118), not one query per speaker.
  • Why it's built this way: the same cross-source degradation rule as its siblings (ADR-002, ADR-006): when a relationship can span physical data sources, EF's navigation is stripped and only the scalar foreign key survives, so hydration has to be a second batched query rather than an Include (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Query/NavigationMetadataProvider.cs:96-99). [Rubric §7, Microservices Readiness] assesses whether the code survives a physical split: this file is the survival kit for the speaker aggregate. [Rubric §8, Data Architecture]: the parent-child link degrades to a scalar FK plus a batched key lookup.

  • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Speaker>, SpeakerNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:76), immediately above the SpeakerEntityQueryService registration it is injected into (:74), and invoked by the read pipeline as the NavigationPopulator.PopulateAsync delegate the base query service passes down (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:321, :534). Compare the entities that need no manual hydration at all: those register NullNavigationPopulator<TEntity> instead (MMCA.ADC.Conference.Application/DependencyInjection.cs:85). SpeakerNavigationPopulatorTests asserts the type binds to INavigationPopulator<Speaker> and that the empty-input guards complete without touching the unit of work (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerNavigationPopulatorTests.cs:16, :20, :24, :35).

  • Caveats / not-in-source: the descriptors cover child collections only. Speaker.LinkedUserId is a scalar cross-module reference into the Identity module (MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:58) and is deliberately not a navigation here, so nothing in this file hydrates a linked user. Nothing in the descriptors filters soft-deleted children either: that exclusion comes from the EF global query filter applied by the read repository the loader resolves (ADR-005). The loader's queries are untracked (MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:80-84), which is why write handlers that need a tracked graph pass an explicit includes array to the repository instead of relying on this populator.

SpeakerQuestionAnswerNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Speakers · MMCA.ADC.Conference.Application/Speakers/SpeakerQuestionAnswerNavigationPopulator.cs:11 · Level 10 · class (sealed)

  • What it is: the navigation populator for SpeakerQuestionAnswer read as its own entity. One navigation: the parent Speaker back-reference (MMCA.ADC.Conference.Application/Speakers/SpeakerQuestionAnswerNavigationPopulator.cs:7-10).
  • Depends on: DeclarativeNavigationPopulator<TEntity> closed over SpeakerQuestionAnswer (:13); FKNavigationDescriptor<TEntity, TChild, TChildId> (:15); IUnitOfWork (:12); the Speaker aggregate as the reference target.
  • Concept reinforced: none new. Structurally identical to SpeakerCategoryItemNavigationPopulator, which teaches the FK direction: the same closed generic over Speaker and SpeakerIdentifierType (:15), PropertyName = nameof(SpeakerQuestionAnswer.Speaker) (:17), ParentKeySelector = e => e.SpeakerId (:18, the get-only FK at MMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:26), ChildForeignKeySelector = child => child.Id (:19), AssignAction calling the public mutator with FirstOrDefault() (:20, SpeakerQuestionAnswer.cs:84), and an empty class body (:23-24). The target property is the same private-set, attributed navigation shape (SpeakerQuestionAnswer.cs:22-23).
  • Where it's used: registered as services.TryAddScoped<INavigationPopulator<SpeakerQuestionAnswer>, SpeakerQuestionAnswerNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:123). This is the one populator in the module with no paired query service, and the registration says so in a comment: the entity has no query service today, and registering the populator future-proofs the one that would be added alongside it (:118-119). Covered by SpeakerQuestionAnswerNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/SpeakerQuestionAnswerNavigationPopulatorTests.cs:16, :20, :24, :35).
  • Caveats / not-in-source: because nothing resolves INavigationPopulator<SpeakerQuestionAnswer> on a read path today, this class is registered but not exercised outside its unit tests. Answers still reach clients as part of a speaker read, through the child-collection descriptor on SpeakerNavigationPopulator, which is a different code path entirely.

SponsorNavigationPopulator

MMCA.ADC.Conference.Application · MMCA.ADC.Conference.Application.Sponsors · MMCA.ADC.Conference.Application/Sponsors/SponsorNavigationPopulator.cs:12 · Level 10 · class (sealed)

  • What it is: the navigation populator for the Sponsor aggregate. One navigation, and it points up: the Event a sponsor belongs to (MMCA.ADC.Conference.Application/Sponsors/SponsorNavigationPopulator.cs:8-11).
  • Depends on: DeclarativeNavigationPopulator<TEntity> closed over Sponsor (:14); FKNavigationDescriptor<TEntity, TChild, TChildId> (:16); IUnitOfWork (:13); the Event aggregate as the reference target.
  • Concept reinforced: none new; see SpeakerCategoryItemNavigationPopulator for the FK direction. What differs is only which key is read and which aggregate is fetched: PropertyName = nameof(Sponsor.Event) (:18), ParentKeySelector = e => e.EventId (:19, over the private-set FK at MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45), ChildForeignKeySelector = child => child.Id (:20), AssignAction = (e, events) => e.SetEvent(events.FirstOrDefault()) (:21) calling the public mutator (Sponsor.cs:202) that writes the attributed, private-set navigation (Sponsor.cs:48-49), and an empty class body (:24-25). Worth noting the shape this reveals: Sponsor is an aggregate root that owns no children of its own, which is why it needs exactly one descriptor and why its delete is the generic DeleteEntityHandler<TEntity, TIdentifierType> that services.AddEntityCrud<Sponsor, SponsorDTO, SponsorIdentifierType, SponsorCreateRequest, SponsorUpdateRequest>() registers wholesale (MMCA.ADC.Conference.Application/DependencyInjection.cs:145, resolving to MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:347-349) rather than a cascade handler of its own, even though it is itself swept up by DeleteEventHandler when its event goes away.
  • Where it's used: registered as services.TryAddScoped<INavigationPopulator<Sponsor>, SponsorNavigationPopulator>() (MMCA.ADC.Conference.Application/DependencyInjection.cs:93), immediately above the closed generic EntityQueryService<TEntity, TEntityDTO, TIdentifierType> registration for sponsors (:91), which is injected into SponsorsController (MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:41). Covered by SponsorNavigationPopulatorTests (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/SponsorNavigationPopulatorTests.cs:16, :20, :24, :35).
  • Caveats / not-in-source: SponsorDTO carries EventId but no Event (MMCA.ADC.Conference.Shared/Sponsors/SponsorDTO.cs:69), and the sponsor mapper projects no event data, so what this populator hydrates does not reach an API response on the read path today. The descriptor keeps the entity self-consistent when a sponsor is materialized with FK includes; it is not currently what any client sees.

⬅ ADC Conference - Domain Model & Module ContractsIndexADC Conference - Infrastructure & Persistence ➡