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) andSystem.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'sInclude(...)) 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'sMusthas 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 withWithErrorCode(...)next to its human-readable message, so an API client or a test can key offActivity.EndTime.BeforeStartwithout 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", codeActivity.StartTime.Required(:99-100).RuleFor(endTimeSelector).NotEmpty(), message "You must enter an End Time", codeActivity.EndTime.Required(:102-103).var startTimeFunc = startTimeSelector.Compile();(:105) turns the start-time expression into an executableFunc<T, DateTime>once, at fragment construction, not per validation call. The third rule then hangs off the end-time selector and uses the two-argumentMust((instance, endTime) => endTime >= startTimeFunc(instance))(:106-107), reporting "End Time must be on or after the Start Time" with codeActivity.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 mirrorsEventDateRangeRules, 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, codeActivity.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-nullableDateTimerejectsdefault(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 atActivityInvariants.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 readonlyfields, 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).Assemblyis refactor-safe (renaming the assembly, moving the file, or restructuring the namespace changes nothing), which is why the codebase prefers an anchor type overAssembly.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 anAssemblyvalue. The class is kept for symmetry with the other layers' anchors and for tooling that wants theAssemblyobject 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
typeofanchor 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, soScanModuleApplicationServices<ClassReference>()reads as "scan the assembly that containsClassReference", 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 neithersealednorstatic, because astaticclass 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 isTAssemblyMarkeronScanModuleApplicationServices, declared in MMCA.Common (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:163-165) with awhere TAssemblyMarker : classconstraint, and its body simply forwardstypeof(TAssemblyMarker).Assemblyto theAssembly-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 anifburied 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:Activitycarries a realEventIdcolumn, so the rule resolves to a published-event id list and comes back as anActivity.EventId IN (...)criteria; no navigation join is involved, so the criteria stays engine-portable andActivitykeeps its by-id boundary toEvent. - 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
GetReadSpecificationAsyncoverride (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?,DateTimeandint. - 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 eachIncludethe 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 overwhere 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 keepinit-only setters and stay immutable. - Walkthrough:
Name(IActivityFieldsRequest.cs:15, non-nullable, the one required string),Description(:18),StartTime(:21) andEndTime(:24) as event-local wall times,VenueName(:27, where empty means the main conference venue),VenueAddress(:30),VenueUrl(:33), andSortOrder(: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, soEventIdis 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, alongsideICreateRequestandICacheInvalidating) 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
EventIdentifierTypeproperty 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) andSystem.Linq.Expressions(BCL). TheEventIdentifierTypein the selector signature (ActivityValidationRules.cs:83) is the module's identifier alias, declared once as aglobal usingin 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
RuleForchain 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 contributesRuleFor(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". BecauseEventIdentifierTypealiasesint,NotEmpty()here rejects0, the default of an unset id, rather than a null. The base's remarks (CommonValidationRules.cs:133-139) note that the same check covers aGuidkey by rejectingGuid.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'sWithOptionalErrorCodehelper (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 noEventIdat 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 contributesGreaterThanOrEqualTo(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 (itsDescriptionMaxLengthconstant),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
RuleForchain 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 contributesMaximumLength(maxLength)and nothing else (CommonValidationRules.cs:56-57), so null and empty are both accepted.DescriptionMaxLengthis2000, declared on ActivityDTO (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:21) and re-exported as aconstby 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-12states 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 (
WithOptionalErrorCodeis 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 atActivityInvariants.cs:19and noEnsure...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 toIActivityFieldsRequest.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-nullablestringselector, versus thestring?of the optional siblings: the compiler enforces at the call site that only a required property can be passed here. The base contributesNotEmpty()plusMaximumLength(...)(CommonValidationRules.cs:44-46), with both messages built from the "Activity Name" label and the max-length message formatted throughstring.Create(CultureInfo.InvariantCulture, ...), which is what keeps the analyzers-as-errors build satisfied about culture-sensitive formatting. The ceiling is200(.../MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:18, re-exported atActivityInvariants.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 errorsActivity.Name.EmptyandActivity.Name.TooLong. Two layers, one constant, two distinguishable failures. - Where it's used: pulled in by ActivityFieldRules<T> (
ActivityValidationRules.cs:135), bound toIActivityFieldsRequest.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 is500(.../MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:27, re-exported atActivityInvariants.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 isEnsureVenueAddressIsValid(ActivityInvariants.cs:58). - Where it's used: pulled in by ActivityFieldRules<T> (
ActivityValidationRules.cs:140), bound toIActivityFieldsRequest.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), ceiling200(.../MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:24, re-exported atActivityInvariants.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 toIActivityFieldsRequest.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'sAbstractValidator<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 aWhen(...)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 executablejavascript:ordata: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). TheWhenpredicate is what makes an empty value pass outright, and the included framework fragment supplies both checks:MaximumLength(2000)andMust(BeAnAbsoluteHttpUrl)with the message "Venue URL must be an absolute http or https URL" (CommonValidationRules.cs:88-90). The scheme predicate delegates toCommonInvariants.EnsureUrlIsWellFormed(CommonValidationRules.cs:92-93), which passes a null or empty value and otherwise requiresUri.TryCreate(url, UriKind.Absolute, ...)to succeed with a scheme of exactlyhttporhttps(MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:293-297,:436-439). The ceiling is2000(.../MMCA.ADC.Conference.Shared/Activities/ActivityDTO.cs:30, re-exported atActivityInvariants.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-83states that intent). A relative path, a bare host, and ajavascript:payload all fail the same rule. Keeping the value a plainstringrather than aUriis 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 aUri(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 callsEnsureOptionalStringMaxLengthand emitsActivity.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 toIActivityFieldsRequest.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'sAbstractValidator<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.Namebinds 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 oneIncludehere, 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 sevenIncludecalls, in the order name, time range, sort order, description, venue name, venue address, venue URL (:135-141). Each one constructs the sibling fragment closed overTand hands it the matching interface property. FluentValidation'sIncludemerges 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-bodiedIncludeof 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 theAddEntityCrudcall forActivitycompletes (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.DependencyInjectionwith itsExtensionsnamespace (theTryAdd*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 writeservices.AddModuleConferenceApplication(settings): the sameextension(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):IEventCascadeDeletionDomainServiceto 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 secondTryAddSingleton<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) andCategory(:77-79) each get three scoped registrations, anINavigationPopulator<T>(their bespoke populators EventNavigationPopulator, SessionNavigationPopulator, SpeakerNavigationPopulator, ConferenceCategoryNavigationPopulator), anIEntityQueryService<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:Eventbinds its delete to the bespoke DeleteEventHandler (:67) because deleting an event has to cascade,Sessionbinds its delete to DeleteSessionHandler (:71),Speakerbinds its query service to SpeakerEntityQueryService (:74), andCategorybinds its delete to DeleteConferenceCategoryHandler (:79). Everything else uses the framework generics unchanged. - Aggregate roots with no navigation properties at all (
:81-84):Questionis the only member of this bucket, and it is the only entity in the whole file registered withNullNavigationPopulator<Question>(:82), the do-nothing populator that satisfies the contract when there is nothing to eager-load, plus the genericEntityQueryServiceandDeleteEntityHandler. - Aggregate roots whose only navigation is the parent Event FK reference (
:86-91):Activity(:87-88) andSponsor(: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 theAddEntityCrudcalls further down. - Child entities (
:93-120):Room,CategoryItem,EventSpeaker,EventQuestionAnswer,SessionSpeaker,SessionCategoryItem,SessionQuestionAnswerandSpeakerCategoryItemeach get their own FK populator (RoomNavigationPopulator, CategoryItemNavigationPopulator, EventSpeakerNavigationPopulator, EventQuestionAnswerNavigationPopulator, SessionSpeakerNavigationPopulator, SessionCategoryItemNavigationPopulator, SessionQuestionAnswerNavigationPopulator, SpeakerCategoryItemNavigationPopulator) plus the baseEntityQueryService, and deliberately no delete handler: children are removed through their aggregate root, never addressed directly by a delete command.SpeakerQuestionAnsweris 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):ISessionBookmarkValidationServiceto SessionBookmarkValidationService (:123) andIEventLiveValidationServiceto 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 theAssemblyoverload at:179) first callsThrowIfPipelineSealed(:182), which fails loudly ifAddApplicationDecorators()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 Scrutorservices.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): oneAddEntityCrud<...>()call each forCategory(:140),Activity(:141) andSponsor(: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 withTryAdd, and then bridges the update command to its request validator withAddCommandRequestValidator(: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 ownCreateXHandler(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 reasonSpeakercannot use the plain CRUD call: its update carries one piece of state the request body must never hold (BR-214'sCallerIsOrganizer), 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 thanAdd*, 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 sameTryAddsemantics 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-entityINavigationPopulatorregistrations 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:
applicationSettingsis accepted and immediately discarded; the "profiler decorators" the comment reserves it for do not exist in this layer today.ISessionizeServiceis 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 twoThrowIfPipelineSealedguards enforce (this method must run beforeAddApplicationDecorators()) 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'sTResultis not a DTO or a page butResult<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 translatedINclause 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). Thequeryparameter 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 forEvent(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:40), projectse => e.Idunder the predicatee => e.IsPublishedwithasTracking: false(PublicConferenceVisibility.cs:42-44), and materializes the sequence once so the caller embeds a stable collection EF can translate intoIN(PublicConferenceVisibility.cs:46-47). Every await in this path is.ConfigureAwait(false), the codebase convention for library code.- The return (
:29-30) wraps anew InlineSpecification<Activity, ActivityIdentifierType>(a => publishedEventIds.Contains(a.EventId))inResult.Success<...>.InlineSpecificationis 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 inPublicConferenceVisibilityrather than repeating theIsPublishedprojection 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'sGetReadSpecificationAsyncoverride (ActivitiesController.cs:69-79), the framework read hook, which short-circuits tonullfor privileged readers (:71-72, guarded byIsPrivilegedat: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 everyIQueryHandler<,>in the assembly. - Caveats / not in source: the controller maps a failed
Resulttonull(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 inPublicConferenceVisibilitymemoizes 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 aCategory: aTitle, aSortorder, and an optionalType. - 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-14is 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 theIf-Matchheader (ADR-035), pulled out bySupportsIfMatchAttribute.RequiredToken(HttpContext)in the action (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:124). The framework'sUpdateEntityCommand<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 atConferenceCategoriesController.cs:118). - Walkthrough:
public record class ConferenceCategoryUpdateRequest(:4, deliberately not sealed, though nothing derives from it today) with threeinitmembers.Titleisrequired string(:7), so the record cannot be constructed without one and a JSON body that omits it fails model binding before any validator runs.Sortis a plainint(:10) that defaults to 0.Typeisstring?(: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 letsConferenceCategoryUpdateApplierbe 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'sUpdateAsync(ConferenceCategoriesController.cs:119-135); named as theTUpdateRequestargument ofAddEntityCrud(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 byConferenceCategoryUpdateRequestValidator; applied to the aggregate byConferenceCategoryUpdateApplier. Its create-side sibling isConferenceCategoryCreateRequest, 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
EventSpeakerjunction 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 inPublicConferenceVisibility. 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 intoEventSpeakersControlleras anIQueryHandler<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 hookGetReadSpecificationAsync(EventSpeakersController.cs:78), which returnsnullfor 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, aName, and aSortorder. - 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 wholeSessionize*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 asealed record, every property isinit-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.Emptyat 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 remainingSessionize*sections cross-reference back to this one rather than repeating the shape. - Walkthrough: three
initproperties,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:
recordgives structural equality and a compact declaration (the same reasoning as theValueObjectdiscussion), andinitplus non-null defaults means System.Text.Json can populate it while callers can never mutate it afterward. - Where it's used: nested inside
SessionizeCategory'sItems(SessionizeModels.cs:56); consumed by the category import path,CategorySyncStrategy.
SessionizeLink
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, aUrl, and aLinkType(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
initstringproperties, all empty-defaulted and JSON-mapped:Title(line 129),Url(line 132),LinkType(line 135). - Why it's built this way:
Urlis a plainstring, not aUri. 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'sLinkscollection (SessionizeModels.cs:110), read bySpeakerSyncStrategy.
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,Questiontext,QuestionType, and aSortorder. - Depends on:
System.Text.Json.Serialization(BCL) only. - Concept: an external-API contract DTO (
SessionizeCategoryItem).[Rubric §9, API & Contract Design]. - Walkthrough: four
initproperties,Id(int, line 28),Question(string, empty default, line 31),QuestionType(string, empty default, line 34),Sort(int, line 37).QuestionTypearrives 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'sQuestions(SessionizeModels.cs:21); imported byQuestionSyncStrategy. Its answers are carried separately, bySessionizeQuestionAnswer.
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
QuestionIdit answers and itsAnswerValue. - Depends on:
System.Text.Json.Serialization(BCL) only. - Concept: an external-API contract DTO (
SessionizeCategoryItem).[Rubric §9, API & Contract Design]. - Walkthrough: two
initproperties,QuestionId(int, line 195) andAnswerValue(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) andSessionizeSession's (SessionizeModels.cs:170)QuestionAnswerscollections.
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
initproperties,Id(int, line 76),Name(string, empty default, line 79),Sort(int, line 82). Structurally identical toSessionizeCategoryItem; 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'sRooms(SessionizeModels.cs:12); imported byRoomSyncStrategyinto the domainRoom. Sessions reference a room byRoomId(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
SessionizeCategoryItemvalues. - Depends on:
SessionizeCategoryItem(itsItemscollection);System.Text.Json.Serialization(BCL). - Concept: an external-API contract DTO (
SessionizeCategoryItem). This is the firstSessionize*record that nests another, which is exactly why it sits one dependency level up.[Rubric §9, API & Contract Design]. - Walkthrough: five
initproperties.Id(int, line 44),Title(string, empty default, line 47), andSort(int, line 50) are the flat fields;Typeisstring?(line 53), so an absent JSON field stays null rather than being flattened to an empty string;ItemsisIReadOnlyList<SessionizeCategoryItem>defaulted to the collection expression[](line 56), so a category with no values deserializes to an empty list. Exposing the collection asIReadOnlyList<T>(notList<T>) keeps the record immutable in practice as well as byinit. - Where it's used: nested inside
SessionizeResponse'sCategories(SessionizeModels.cs:9); both the category and its items are reconciled against the domainCategorybyCategorySyncStrategy.
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(itsQuestionAnswerslist, 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
initproperties (SessionizeModels.cs:141-188). Five groups are worth knowing: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.StartsAtandEndsAtareDateTime?(lines 152 and 155), so an unscheduled session round-trips with nulls instead of a deserialization error.SessionSyncStrategyvalidates 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).SpeakersisIReadOnlyList<Guid>(line 164),CategoryItemsisIReadOnlyList<int>(line 167), andRoomIdisint?(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.Description,LiveUrl,RecordingUrl, andStatusare nullable strings (lines 149, 176, 179, 182);LiveUrlandRecordingUrlstaystringfor the same reason asSessionizeLink'sUrl.Title(line 146) is the one non-nullable string, empty-defaulted.- Four booleans classify the session:
IsServiceSession(line 158) andIsPlenumSession(line 161) mark non-talk and plenary slots,IsInformed(line 185) andIsConfirmed(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'sSessions(SessionizeModels.cs:18); imported bySessionSyncStrategyunderRefreshFromSessionizeHandler.
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
initproperties (SessionizeModels.cs:88-122).Idis aGuid(line 89), unlike theintids of every other Sessionize entity in this file. The optional profile fieldsBio(line 98),TagLine(line 101),ProfilePicture(line 104), andFullName(line 116) are nullable strings, whileFirstName(line 92) andLastName(line 95) are empty-defaulted non-nullable ones.IsTopSpeakeris abool(line 107). Four collections,Links(line 110),Sessions(IReadOnlyList<int>, line 113),CategoryItems(IReadOnlyList<int>, line 119), andQuestionAnswers(line 122), are allIReadOnlyList<T>defaulted to[]. - Why it's built this way: the
Guidspeaker id lines up with the Conference module'sSpeakerIdentifierType = Guidalias, so the import can carry a Sessionize speaker id straight into a domainSpeakerkey without a conversion or a lookup table. That bothFullNameand theFirstName/LastNamepair exist is Sessionize's redundancy, not the module's: the wire model keeps both and letsSpeakerSyncStrategychoose. - Where it's used: nested inside
SessionizeResponse'sSpeakers(SessionizeModels.cs:15); imported bySpeakerSyncStrategy, 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 anEventSpeakerrow (SpeakerSyncStrategy.cs:59), which is whyGetPublicEventSpeakerFilterHandlercannot 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
SessionizeCategoryItemtaught.[Rubric §9, API & Contract Design]. This is the objectISessionizeServicereturns and the single input every Sessionize sync strategy reads from. - Walkthrough: five
initIReadOnlyList<...>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 therequiredResponseproperty ofSessionizeSyncContext(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/UseCases/RefreshFromSessionize/SessionizeSyncContext.cs:13), whichRefreshFromSessionizeHandlerbuilds under theRefreshFromSessionizeCommanduse 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
SessionizeResponseornullwhen the response is empty. - Depends on:
SessionizeResponse(its return type). Nothing else, noHttpClient, 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 addresshttps://sessionize.com/api/v2/viaservices.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 substitutesFakeSessionizeService(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).sessionizeCodeis 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 trailingCancellationTokenfollows 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 asGetAllAsync(@event.SessionizeCode, cancellationToken)(RefreshFromSessionizeHandler.cs:82). Note that it is not registered by this layer'sDependencyInjection: 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
ConferenceCategoryUpdateRequestand a loadedCategoryand calls the aggregate's own guardedUpdatemethod, so the framework's generic update handler never has to know a category field name. - Depends on:
IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>fromMMCA.Common.Application.Interfaces(:2,13), theCategoryaggregate (:1,13),ConferenceCategoryUpdateRequest(:13), andResultfromMMCA.Common.Shared.Abstractions(:3,16).ConferenceCategoryIdentifierTypeis 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 bareResultrather 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:
- 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. ApplyAsync(:16) guards both arguments withArgumentNullException.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 aResultfailure.- The body is one delegation, wrapped in
Task.FromResultbecause there is nothing to await:entity.Update(request.Title, request.Sort, request.Type)(:21-24). Nothing in this file writes a property. - Inside the aggregate,
Category.Update(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/Category.cs:84) is where the rules live: it combinesCategoryInvariants.EnsureTitleIsValidand returns the failure before touching state (Category.cs:86-89), assigns the three properties (:91-93), and raisesCategoryChangedwithDomainEntityState.Updated(:95). A refused title therefore never mutates the tracked entity, which is exactly the contract the interface documents.
- The class declaration binds the three type arguments once:
- Why it's built this way: the module's
DependencyInjectionspells out the trade explicitly (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:135-143). OneAddEntityCrud<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 usesTryAdd: the module's ownCreateConferenceCategoryHandlerkeeps 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 theSpeakerpath 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 everyIEntityUpdateApplier<,,>implementation (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:227); resolved as a constructor dependency of the genericUpdateEntityHandler(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/UpdateEntityHandler.cs:50), which serves thePUT /conferencecategories/{id}action onConferenceCategoriesController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:39and: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 realUpdateEntityHandlerover 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 toErrorType.NotFound(:69), a whitespace-only title that fails the domain invariant and, crucially, assertsSaveChangesAsyncwas 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 oneInclude. - Depends on:
AbstractValidator<T>(FluentValidation,:1,7),ConferenceCategoryUpdateRequest(:7), and the sharedConferenceCategoryTitleRules<T>rule set fromMMCA.ADC.Conference.Application.Categories.Validation(:2,10). - Concept: none new; this is
Includecomposition (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 anUpdateEntityCommand<TEntity, TUpdateRequest, TIdentifierType>. The bridge is registered by the sameAddEntityCrudcall that registers the handler: aCommandRequestValidator<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 whyTryAddstill 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()underCategory.Title.Required(:19) andMaximumLength(CategoryInvariants.TitleMaxLength)underCategory.Title.MaxLength(:20). That max length is not a literal:CategoryInvariantsreads it fromConferenceCategoryDTO.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:
SortandTypecarry no rules here, and that is a decision rather than a gap.Sorthas no field-level business constraint on a category (its child items do get one, throughCategoryItemSortRules<T>over the framework'sNonNegativeIntRules<T>), andTypeis 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, whileCategoryInvariants.EnsureTitleIsValidinsideCategory.Updateis 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) asIValidator<ConferenceCategoryUpdateRequest>, then reached through the command bridge above wheneverPUT /conferencecategories/{id}dispatches (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:126-128). The sameConferenceCategoryTitleRules<T>object is included by the create-sideConferenceCategoryCreateRequestValidatoragainst a different request type, which is the whole point of parameterizing the rule set byT. - Testing:
ConferenceCategoryUpdateRequestValidatorTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Categories/Validation/ConferenceCategoryUpdateRequestValidatorTests.cs:6), two tests using FluentValidation'sTestValidateAsynchelper: a valid request produces no errors (:20), and a request built withwith { Title = string.Empty }produces an error onTitle(: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 asksPublicConferenceVisibilitytwice, once for the published event ids and once for the visible speaker ids, and returns anEventSpeaker.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 baseSpecification<TEntity, TIdentifierType>,EventSpeaker, andResult. It implementsIQueryHandler<in TQuery, TResult>toResult<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:GetPublicRoomFilterHandlerfollows the event,GetPublicSpeakerFilterHandlerfollows 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 anEventSpeakerrow for every speaker in the response, which you can read inSpeakerSyncStrategyitself, where every synced speaker without an active link getscontext.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 theEventtable, 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 keepingPublicConferenceVisibility's API narrow. - Walkthrough:
- Resolve the published event ids:
PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken)(:31-33). Inside the resolver that is one scalar projection ofEvent.Idfiltered byIsPublished, 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 toIN(PublicConferenceVisibility.cs:46-47). - 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 theSessionSpeakerjoin projected down to distinct speaker ids (:126-133). - Wrap
es => eventIds.Contains(es.EventId) && speakerIds.Contains(es.SpeakerId)in anInlineSpecification<TEntity, TIdentifierType>and returnResult.Success(:42-44). There is no failure path: the handler cannot fail on its own terms.
- Resolve the published event ids:
- 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 intoContains, 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 theEventSpeakerjoin is never read back as a visibility grant (PublicConferenceVisibility.cs:97-103). - Where it's used:
EventSpeakersControllerinjects it as anIQueryHandler(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 hookGetReadSpecificationAsync(:72-82), which short-circuits tonullfor privileged readers (:75-76, backed bycurrentUserService.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 sharedHandlerTestBase<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 theEventprojection, 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.EventIdis get-only and written by EF, so a row built through the factory in memory carries the default id, and the fixture usesdefaultas its row event id (:21-22).[Rubric §14, Testability]: the speaker leg is the rule most likely to be dropped as redundant, and:105is the test that would catch it. - Caveats / not-in-source: the controller maps a failed
Resulttonull, 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 twoINlists 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 asnew 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.Roomcarries a realEventIdcolumn (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 aRoom.EventId IN (...)criteria. No navigation join is involved, which keeps the criteria engine-portable (ADR-018) and keepsRoom's reference toEventa 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 itsGetReadSpecificationAsyncoverride (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
intproperties, bothinit-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 bareintmeans a future step can report a third number by adding oneinitproperty, 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 isrequired, so a strategy that has nothing secondary to report constructsnew SessionizeSyncResult { PrimarySynced = n }and leaves the other at its default of zero, which is what four of the five strategies do (for exampleRoomSyncStrategy.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 atSessionizeSyncWarnings.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 oneinternal staticmethod 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:
internalandstaticbecause 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:IFeatureGatedmakes the whole Sessionize integration a runtime toggle. - Walkthrough: the positional parameter
EventId(RefreshFromSessionizeCommand.cs:13).CachePrefixreturns$"{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.FeatureNamereturnsConferenceFeatures.SessionizeIntegration(RefreshFromSessionizeCommand.cs:19), whose value is the string"Conference.SessionizeIntegration"(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs:15).ITransactionalis 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:Warningsisinit-only yet holds aList<string>whose contents every step appends to, andSkippedSoftDeletedis 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 itsRoomsandEventSpeakersnavigations, 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'sSyncAsync. - Caveats: nothing in the type enforces the sequential assumption.
List<string>and theintcounter 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 exampleMMCA.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 noOrderproperty 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
intgives 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.Globalizationfor 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.
- Bulk pre-load. The strategy resolves the repository once (
CategorySyncStrategy.cs:16), projects the feed's ids (:21) and issues a singleGetByIdsAsyncwithincludes: [nameof(Category.CategoryItems)],asTracking: trueandignoreQueryFilters: true(:22-27), then indexes the result by id (:28). One query replaces NGetByIdAsynccalls.[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. - Discriminate. For each feed row (
:32): if a stored row exists and is soft-deleted, bumpcontext.SkippedSoftDeletedand skip (:36-40, BR-136). If it exists and is live, call the aggregate'sUpdate(:42). If it does not exist, go to theCreatefactory (:99). - Sync children.
SyncCategoryItems(:62-90) compares feed items against the loadedCategoryItemscollection by id, skips soft-deleted ones (:71-75), and routes the rest toUpdateCategoryItemorAddCategoryItemon the parent aggregate (:79-83), never to a child repository. - 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 onCategory, and category items are only ever reached through their parent, so the aggregate boundary holds even under a bulk import.- Bulk pre-load. The strategy resolves the repository once (
Walkthrough of the specifics:
CreateNewCategory(:92-118) is where the failure policy shows.Category.Createreturns a Result; on failure the strategy appends a warning naming the title and id and quotingSessionizeSyncWarnings.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 aref intparameter (: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: trueis 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 becomeCategoriesSyncedandCategoryItemsSyncedon the response (RefreshFromSessionizeHandler.cs:146-147). Covered byMMCA.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.ManualIdRangeStartandManualIdRangeEndare999_999_000and999_999_999(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:40and: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 flatteningQuestionAnswersacross the feed's speakers and sessions. - Reserved-id filter (
:30-41): the guard above, producingvalidQuestions. - Bulk pre-load (
:44-50): oneGetByIdsAsyncover 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 withexistingQuestion.IsRequiredpassed back in (:69), so the organizer's required flag survives a re-sync. A new question is created withisRequired: falseandquestionSource: "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).
- Answer-derived classification (
- 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
IsRequiredand 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 asQuestionsSynced(RefreshFromSessionizeHandler.cs:149).MapSessionizeQuestionTypeisinternal staticso it can be exercised directly: the assembly grantsInternalsVisibleToto the application test project (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/MMCA.ADC.Conference.Application.csproj:3), and the tests live atMMCA.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.
Roomis not an aggregate root, so the strategy resolvesGetReadRepository<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 throughcontext.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-88calling the predicate at:93-102):ExcludeReservedIdsdrops any feed room whose id falls betweenEventInvariants.RoomManualIdRangeStartandRoomManualIdRangeEnd, which are999_999_000and999_999_999(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:66and:68), warning for each (:97). This mirrors the question-side guard exactly. - Unscoped id lookup (
:43-49): oneGetByIdsAsyncwithasTracking: trueandignoreQueryFilters: true, and deliberately not filtered byEventId. 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 ownRoomscollection 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 itsEventIdis 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 carriesEventId0 until EF assigns it on save. - Apply (
:109-122): a three-way switch expression. No stored room meansAddRoom(:112); a soft-deleted one meansRestoreRoom(:113); otherwiseUpdateRoom, which readsCapacity,Floor,LocationandAccessibilityInfoback 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.
- Reserved-band filter (
- Why it's built this way: the whole refresh is one transaction (
ITransactionalon 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 asRoomsSynced(RefreshFromSessionizeHandler.cs:148). Covered byMMCA.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 loadedSessionSpeakers,SessionCategoryItemsandSessionQuestionAnswerscollections 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 callsRestoreSessionSpeaker(:132) orRestoreSessionCategoryItem(:149), and only adds a new row when none exists (:136and: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):ValidateSessionTimeswarns when a session starts before the event'sStartDateor ends after itsEndDate(BR-86,:56-64) and whenEndsAtis at or beforeStartsAt(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, whileAccessibilityInfoandResourceLinksare read back off the stored session (:93) because Sessionize does not send them. A miss goes toSession.Create(:97-103), which receivesnullfor 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 onQuestionIdwith anIsDeletedguard (:165-166). - Batch add and report (
:45-50).
- Bulk pre-load (
- 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
IsDeletedcheck, and preferring restore over insert, is what delivers that. - Where it's used: instance four of the handler's array (
RefreshFromSessionizeHandler.cs:35), reported asSessionsSynced(RefreshFromSessionizeHandler.cs:151). Covered byMMCA.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
EventSpeakerlink is simply absent fromcontext.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 forEventSpeaker(:85), queries this event's deleted links withignoreQueryFilters: trueandasTracking: 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) withSpeakerCategoryItemsandSpeakerQuestionAnswersincluded, filters off, tracking on. - Social-link extraction (
:183-215):ExtractSocialLinkswalks the feed's links and matchesLinkTypecase-insensitively, sending"Twitter"throughExtractTwitterHandle(: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 S5332around 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 withexistingSpeaker.Email?.Valuepassed back in (:120) so the stored email survives, since the feed does not carry it. A new speaker goes throughSpeaker.Createwith 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).
- Bulk pre-load (
- 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,
IsRequiredin 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 asSpeakersSynced(RefreshFromSessionizeHandler.cs:150). Covered byMMCA.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> toResult<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:
HandleAsync(:21-23) takes the marker query and aCancellationToken.- Resolve the published event ids:
PublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken)(:25-27). Inside the shared resolver that is a scalar, untracked projection ofEvent.IdwhereIsPublished, materialized once so the list embedded in the predicate is stable and EF-translatable toIN(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Common/PublicConferenceVisibility.cs:36-47). - Wrap
r => publishedEventIds.Contains(r.EventId)in an InlineSpecification<TEntity, TIdentifierType> and returnResult.Success(:29-30). Nothing here can fail, soResultis 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).Roomis the easy case for that rule because it carries a realEventIdcolumn (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 restatingIsPublishedhere is what keeps one definition of "published" behind every public read. - Where it's used:
RoomsControllerinjects it as anIQueryHandler(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:97) and calls it from itsGetReadSpecificationAsyncoverride (RoomsController.cs:120-130), which short-circuits tonullwhenIsPrivileged(:123-124, backed bycurrentUserService.IsPrivilegedConferenceReader()at:104), so Organizer and ContentEditor readers see every room.GetReadSpecificationAsyncis 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 tobase; each also carries[OutputCache(PolicyName = "RoomsCache")](:138,:154,:175,:188), and the three write actions evict theconference:roomsoutput-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 (:199and:214unpaged,:229and:245paged,:261and:277lookup,:292and:312by-id).SetupPublicRoomFilterstands in a filter of its own,r => r.EventId == 1(:329-333), andVerifyFilterNeverResolved(: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
Resulttonull, 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 theINlist 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.CircuitBreakerandPolly.Timeout(for the two rejection types it catches) andSystem.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'sTimeoutRejectedExceptionandBrokenCircuitException, andJsonExceptionorNotSupportedException. 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 fromAddServiceDefaults, 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 readonlybecause the strategies hold no state; all state lives in the per-command context. - Primary constructor (
:19-24): five dependencies.sealed partialis what allows the[LoggerMessage]source-generated log method at the bottom of the file (:173-174). - Load the aggregate (
:43-54):GetByIdAsyncwithRoomsandEventSpeakersincluded andasTracking: true, returningError.NotFoundwhen the event is missing. - Precondition, a configured code (
:57-64, BR-6): a blankSessionizeCodefails withEvent.Sessionize.NoCode. - Precondition, the throttle (
:67-75, BR-63): ifLastSessionizeRefreshOnis less than five minutes beforetimeProvider.GetUtcNow(), the handler fails withEvent.Sessionize.Throttledand never calls the API. Time comes from an injectedTimeProvider, 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 fiveSyncAsynccalls run sequentially, collecting a SessionizeSyncResult each. - Summarize skips (
:128-131): a non-zeroSkippedSoftDeletedbecomes one final warning line naming the count and BR-136. - Stamp and save (
:134-139):RecordSessionizeRefreshwrites the current user id and timestamp onto the aggregate, thenunitOfWork.RequestIdentityInsert()(:138) is called before the singleSaveChangesAsync(: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 inSET IDENTITY_INSERT ON/OFFper 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 atDbContextFactory.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]throughresults[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.- Static strategy array (
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
EventsControllerasICommandHandler<RefreshFromSessionizeCommand, Result<RefreshFromSessionizeResultDTO>>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:54) and invoked fromRefreshAsync(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.Throttledbecomes a 429 with aRetry-Afterof 300 seconds (EventsController.cs:340-343) andEvent.Sessionize.Unavailablebecomes a 502 (EventsController.cs:347-348). Unit tests live atMMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/UseCases/RefreshFromSessionize/RefreshFromSessionizeHandlerTests.cs:16, with an integration tier atMMCA.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. BothRecordSessionizeRefreshcalls dereferencecurrentUserService.UserId!.Value(:98and: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) andSystem.Linq.Expressions.Expression<>(BCL). No first-party types: the file'sMMCA.ADC.Conference.Domain.EventsandMMCA.Common.Application.Validationimports (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-argumentMustoverload 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 anotherInclude(...)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 twoExpression<Func<T, DateOnly>>selectors,startDateSelectorandendDateSelector. It registersNotEmptyon each, with the distinct codesEvent.StartDate.Required(:120-121) andEvent.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 callsMust((instance, endDate) => endDate >= startDateFunc(instance))with the message "End Date must be on or after the Start Date" and the codeEvent.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 toCommonInvariants.EnsureEndIsNotBeforeStartunder the codeEvent.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> asnew 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 byEventCreateRequestValidatorTests.Validate_WithEndDateBeforeStartDate_ReturnsError(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Events/Validation/EventCreateRequestValidatorTests.cs:55). - Caveats / not-in-source:
NotEmptyon aDateOnlyrejectsdefault(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).DateOnlyis 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 : IEventFieldsRequestand 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) andTimeZone(:24) are non-nullablestring;StartDate(:18) andEndDate(:21) areDateOnly;OrganizerContactEmail(:27),SponsorshipPacketUrl(:30), andTicketingUrl(:33) are nullablestring?, 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 codeEvent.QuestionModerationDefault.Invalidafter 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, alongsideICreateRequestandICacheInvalidating) 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.Validationnamespace, not beside the requests, so both request files take ausing 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>andSystem.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 theGreaterThan(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), chainsGreaterThan(0)with the message "Capacity must be greater than 0" and the error codeRoom.Capacity.NotPositive(:41-42), then applies the null-guardWhenclause (: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 asEventInvariants.EnsureRoomCapacityIsValid, which delegates toCommonInvariants.EnsureNullableIntIsPositiveunder the codeRoom.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 onp => p.Capacity. Both branches are pinned byAddRoomCommandValidatorTests(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
Whenpredicate here callsselector.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), plusSystem.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
errorCodeparameter (CommonValidationRules.cs:124) and applies it to every rule it declares through the internalWithOptionalErrorCodehelper (: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.Negativetravels 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 anExpression<Func<T, int>>selector, forwardingbase(selector, "Sort", "Room.Sort.Negative")(:29-30). The base registers a singleGreaterThanOrEqualTo(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 thanGreaterThan(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 onp => p.Sort, alongside RoomCapacityRules<T>. Both validators have a negative-sort test (CommandValidatorTests.cs:92,:137). - Caveats / not-in-source: nothing in
EventInvariantsmirrors 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 toEventDTO.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 theNotEmptyplusMaximumLengthwiring to the base (CommonValidationRules.cs:43-46).[Rubric §4, DDD]assesses ubiquitous language in code: the class is namedEventNameRulesafter 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 callbase(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 codesEvent.Name.EmptyandEvent.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> onp => p.Name(EventValidationRules.cs:145), which reaches both EventCreateRequestValidator and EventUpdateRequestValidator. The empty-name case is pinned atEventCreateRequestValidatorTests.cs:33. - Caveats / not-in-source: this fragment passes no
errorCodeto 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 inheritsAbstractValidator<T>directly and usesSystem.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 withNotEmpty(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 wholeIncludein FluentValidation'sWhen(...), 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 callsWhen(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, andMaximumLength, 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.PublicEventDetailseeds_supportEmailfromConfiguration["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 whenEvent.OrganizerContactEmailis 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> asp => 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
stringwhile the underlying interface member isstring?(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Events/Validation/IEventFieldsRequest.cs:27), which is why the call site passes the null-forgivingp => p.OrganizerContactEmail!. The!only silences the compiler; the runtime null is handled by theWhenguard, 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 noEnsure...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 absolutehttporhttpsURL. - 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 inheritsAbstractValidator<T>directly. Transitively it depends on CommonInvariants, because the framework fragment delegates its scheme predicate toCommonInvariants.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
Includea shared fragment inside aWhenguard. What it adds is which fragment it wraps. A length-only bound acceptsjavascript:alert(1)anddata: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 addsMust(BeAnAbsoluteHttpUrl)on top of the sameMaximumLength, and the predicate resolves throughCommonInvariants.EnsureUrlIsWellFormed(MMCA.Common/Source/Core/MMCA.Common.Domain/Invariants/CommonInvariants.cs:293-297), whose privateIsAbsoluteHttpUrlrequiresUri.TryCreate(url, UriKind.Absolute, ...)to succeed with an ordinal scheme match againsthttporhttps(: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 aUri(the suppression justification atCommonInvariants.cs:289-292spells that reasoning out).[Rubric §26, Front-End Security]assesses the same question from the browser's side: the value ends up in anHref, 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 nullablestring?here as against the non-nullable selector of its email sibling, compiles it (:82), and registersWhen(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new AbsoluteUrlRules<T>(selector, "Sponsorship Packet URL", EventInvariants.SponsorshipPacketUrlMaxLength)))(:84-85). The shared base contributes two rules:MaximumLengthwith the message "Sponsorship Packet URL cannot be longer than 2000 characters", thenMust(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:PublicSponsorListrenders 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 atPublicSponsorList.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> onp => 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 adata:payload (EventCreateRequestValidatorTests.cs:218-221), and the optional and overlong cases at:130and:153. - Caveats / not-in-source: the wrapped base sets no
WithErrorCodebecause 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: noEnsure...method in the Conference module callsEnsureUrlIsWellFormed, 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 inheritsAbstractValidator<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, aWhenpresence guard, and anIncludeof 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 registersWhen(x => !string.IsNullOrWhiteSpace(accessor(x)), () => Include(new AbsoluteUrlRules<T>(selector, "Ticketing URL", EventInvariants.TicketingUrlMaxLength)))(:104-105). The shared base contributes theMaximumLengthrule whose message is "Ticketing URL cannot be longer than 2000 characters" and theMust(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 atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Home/ADCHome.razor:71-75and the public event page atMMCA.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> onp => 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 ajavascript:payload (EventCreateRequestValidatorTests.cs:205-209). - Caveats / not-in-source: as with the sponsorship URL, no
WithErrorCodeis 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 atADCHome.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 inheritsAbstractValidator<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 aMust(...)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 onlyTimeZoneNotFoundException(: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):NotEmptywith codeEvent.TimeZone.Required(:30),MaximumLength(EventInvariants.TimeZoneMaxLength)with codeEvent.TimeZone.MaxLength(:31), andMust(BeAValidIanaTimeZone)with codeEvent.TimeZone.InvalidIanaand a message naming'America/New_York'as the example form (:32).BeAValidIanaTimeZone(:34-48) returnstrueimmediately for null or whitespace (:36-37), with an inline comment noting thatNotEmptyalready covers that branch, then callsTimeZoneInfo.FindSystemTimeZoneById(timeZone)inside atry(:39-43) and returnsfalseonly onTimeZoneNotFoundException(:44-47). - Why it's built this way: returning
truefor the empty case avoids emitting two messages for one missing field. Delegating the identifier check toTimeZoneInforeuses the platform's canonical time-zone database instead of hand-maintaining a list of identifiers. The domain repeats all three checks inEventInvariants.EnsureTimeZoneIsValid(EventInvariants.cs:82-102, with the codesEvent.TimeZone.Empty,Event.TimeZone.TooLong, andEvent.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> onp => p.TimeZone(EventValidationRules.cs:146), reaching both EventCreateRequestValidator and EventUpdateRequestValidator. The empty and valid-identifier cases are pinned atEventCreateRequestValidatorTests.cs:44and:69. - Caveats / not-in-source:
FindSystemTimeZoneByIdresolves 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>declaresMaximumLengthand 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 forwardsbase(selector, "Accessibility Info", EventInvariants.RoomAccessibilityInfoMaxLength, "Room.AccessibilityInfo.MaxLength")from a constructor taking anExpression<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
errorCodeargument 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 onp => p.AccessibilityInfo; the overlong case is pinned atMMCA.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 inheritedMaximumLengthrule, noNotEmpty. - Walkthrough:
: OptionalStringRules<T>(RoomValidationRules.cs:52) with the constructor forwardingbase(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 onp => p.Floor; the overlong case is pinned atCommandValidatorTests.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 forwardingbase(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 onp => p.Location; the overlong case is pinned atCommandValidatorTests.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 optionalerrorCodeis 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.RequiredandRoom.Name.MaxLengthare separately addressable, whereas the event name yields a message only. - Walkthrough: the constructor takes a single
Expression<Func<T, string>>selector (:16) and chainsNotEmptywith the message "You must enter a Room Name" and the codeRoom.Name.Required(:17-18), thenMaximumLength(EventInvariants.RoomNameMaxLength)with the codeRoom.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 onp => p.Name; empty and overlong cases are pinned atCommandValidatorTests.cs:75,:84, and:129. - Caveats / not-in-source: the length rule duplicates a domain-side check:
EventInvariants.EnsureRoomNameIsValidenforces the same constant with the codesRoom.Name.EmptyandRoom.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 anIncludeplus whatever its own operation adds. That is a different arrangement from a shared base validator: composition throughIncludemeans 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, sixIncludelines 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 sixIncludecalls in field order:EventNameRules<T>onp => p.Name(:145),EventTimeZoneRules<T>onp => p.TimeZone(:146),EventDateRangeRules<T>on thep => p.StartDate, p => p.EndDatepair (:147),EventOrganizerContactEmailRules<T>onp => p.OrganizerContactEmail!(:148, the null-forgiving operator bridging the interface'sstring?to that fragment's non-nullable selector),EventSponsorshipPacketUrlRules<T>onp => p.SponsorshipPacketUrl(:149), andEventTicketingUrlRules<T>onp => p.TicketingUrl(:150). Every lambda binds againstT's interface members, which is what thewhere T : IEventFieldsRequestconstraint (: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 codeEvent.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>andEventFieldRules<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 byEventCreateRequestValidatorTestsandEventUpdateRequestValidatorTests(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
Includelines 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
Answerslist and carries nothing else. - Depends on: the
QuestionIdentifierTypealias (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs) and a BCLstring. 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: hoistingSessionIdout 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),QuestionIdandAnswerValue, documented individually above the declaration (:6-8). No body, no defaults, no validation of its own: the per-element rule (AnswerValuemust be non-empty) is applied by BatchAddSessionQuestionAnswersCommandValidator through aRuleForEachchild-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
recordgives 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-202is 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
EventIdentifierTypealias, anintin 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,
EventIdof typeEventIdentifierType(: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 injectedIQueryHandler<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
EventIdentifierTypealias (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8) and a BCLdouble. 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 of0.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 omitsminimumSimilaritynever 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, andMinimumSimilaritydefaulted to0.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 whatHandleAsync_TreatsMinimumSimilarityAsInclusiveLowerBoundpins (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), bindingminimumSimilarityfrom 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 noIValidator<TQuery>is registered (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/ValidatingQueryDecorator.cs:69-72), and no validator exists for this query: theGetContentSimilarityfolder holds only the query, the handler, and SessionSimilarityCalculator. A caller passing5.0therefore 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
ConferenceCategoryIdentifierTypealias, anintin 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).Nameis the locality tier name, documented with the example "Atlanta and Suburbs" (:11);CategoryIdis the identifier of the locality category owning the item (:12). The type isinternal 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 writeslookup[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 whoseCategoryIdis 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 arecordgives 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
DecisionSupportfolder, as the value type of theIReadOnlyDictionary<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 bySpeakerLocalityHelperTests(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) andQuestionType(:13) are plainrequired stringmembers 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"), andQuestionType(:13, documented as "Rating", "Text", or "Email") arerequired stringwithinitsetters.Sort(:16) andIsRequired(:19) are plain value members that default to0andfalse. Note the near-miss in naming:IsRequiredis the survey question's own "an attendee must answer this" flag, not the C#requiredmodifier that governs three of its siblings. There is noRowVersionmember: the concurrency token travels beside the payload on UpdateQuestionCommand (UpdateQuestionCommand.cs:14), read from theIf-Matchheader 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 fromMMCA.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>andSystem.Linqfrom the BCL, plus theCategoryItemIdentifierTypealias 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,SessionSimilarityCalculatorTestsdrives 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.6andKeywordWeight = 0.4(:11-12), the only two tuning constants,private constand therefore not configurable at runtime.StopWords, aFrozenSet<string>built once at type initialization withStringComparer.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 aReadOnlySpan<char>with a manual index loop rather thanstring.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 toAddTokenIfNotStopWord(:123-130), which uppercases withToUpperInvariant(the summary at:37notes 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) returns0.0when 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'sContains(:85-88), then divides the intersection count bysetA.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 aList<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 staticwith 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:
TokenizeTextwhile pre-computing per-session keyword sets (GetContentSimilarityHandler.cs:58),CalculateSimilarityinside the pairwise loop (:68-72), andGetIntersectiontwice 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, orPending(GetCategoryDistributionHandler.cs:94-99). - Depends on: nothing. It is a bare
enumwith default backing type and no attributes, declaredprivateinside 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
privaterather 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: comparingbucket == StatusBucket.AcceptQueueinside 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 orSessionStatuses.AcceptedtoAccepted,SessionStatuses.AcceptQueuetoAcceptQueue, and everything else toPending, all withOrdinalIgnoreCasecomparison. Declined sessions never reach it:CountSessionsPerCategoryItemfilters them out first viaIsDeclined(:44,:114-115), which is why there is noDeclinedmember. - 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 byClassifyStatus(:101), carried in the(CategoryItemId, Bucket)pairs thatCountSessionsPerCategoryItemflattens 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 noDeclinedmember even though the persisted vocabulary has that status: the filter upstream is the only thing keeping a declined session from being counted asPending.
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> fromMMCA.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.
QuestionEntityandQuestionTypearerequiredstrings with no rule here, even though only certain values are legal. Their legality is asserted twice further in: by QuestionInvariants insideQuestion.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 includesnew QuestionTextRules<QuestionUpdateRequest>(p => p.QuestionText). That rule set isNotEmptyplusMaximumLength(QuestionInvariants.QuestionTextMaxLength), carrying the codesQuestion.QuestionText.RequiredandQuestion.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 identicalQuestionTextRules<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 theCategoryItemIdentifierType/ConferenceCategoryIdentifierTypealiases (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7). Nothing external beyond the BCL: no repository, noIUnitOfWork, 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
staticwith 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:SpeakerLocalityHelperTestsconstructs 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 inBuildLocalityLookup, 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), aprivate constholding121854, 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 whoseTitlecontains "traveling" or "Where are you based", case-insensitively (:98-102). A category that matches neither but carries the known id is remembered asfallback(: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 writeslookup[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'sSpeakerCategoryItems, skipping soft-deleted assignments (:45-46) and assignments whose item is not in the lookup (:48-49), and keeps the entry with the highestCategoryIdseen (:53-57). It returnsnullwhen the speaker has no locality assignment at all (:40,:60).IsLocalSpeaker(string? localityTier)(:69) returnsfalsefor 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
FindLocalityCategoriesandBuildLocalityLookuponce andGetLocalityTierin 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,
IsLocalSpeakerhas no production caller: the only references are its declaration (:69) and the theory inSpeakerLocalityHelperTests, 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 itsFullName(:1,:18).QuestionIdentifierTypeis 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 theIf-Matchheader, 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-Matchis 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[], notstring, 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 bySupportsIfMatchAttribute.RequiredToken(HttpContext)(:122), against the injectedICommandHandler<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 itsFullName(:1,:27), and theSessionIdentifierTypealias. - Concept introduced:
ITransactionalas 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 oneSaveChangesAsyncmeans 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),SessionIdandAnswerstyped asIReadOnlyList<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 injectedICommandHandler<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 sameIdempotency-Keyreplays 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 fromMMCA.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 returningResult<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 passasTracking: 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 oneGetCategoryDistributionfolder, 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 withSessionCategoryItemsincluded, filtered tos.EventId == query.EventId && !s.IsServiceSession(:24-28), then loads every category with itsCategoryItems(:30-33), with no event filter because categories are global.CountSessionsPerCategoryItem(:41-64) drops declined sessions viaIsDeclined(: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 orSessionStatuses.AcceptedasAccepted,SessionStatuses.AcceptQueueasAcceptQueue, and anything else asPending, all withOrdinalIgnoreCasecomparison.BuildCategoryGroups(:66-92) keeps only non-deleted categories that have at least one counted item (:70), orders categories bySort(:71) and items bySort(:78), and projects each item into aCategoryItemDistributionwith its four counts, usingTryGetValueso an uncounted item yields zeros (:81-90). The handler returnsResult.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 fromGET SessionSelection/categories/{eventId}(:53-65), an endpoint gated on theSessionSelectionManagepermission at the controller level (:29) and output-cached under theConferenceCachepolicy (:55). Behavior is pinned byGetCategoryDistributionHandlerTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetCategoryDistributionHandlerTests.cs:13). - Caveats / not-in-source: a null
Session.Statusis counted asAccepted(: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 choosingAcceptedoverPendingas the null default is not stated there. The handler also resolves the read-writeGetRepository(:21-22) although it only reads: IUnitOfWork exposes a narrowerGetReadRepositoryalongside it (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:19versus: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: thewherepredicate excludes service sessions and declined ones in the database (:29-31). Third, the result is capped atMaxPairs = 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 withSessionCategoryItemsincluded andasTracking: 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), whereCategoryItemsskips soft-deleted links (:56) andKeywordscomes fromSessionSimilarityCalculator.TokenizeText(s.Title + " " + s.Description)(:58). The pairwise loop scores each combination and keeps the pair whenscore >= query.MinimumSimilarity(:64-79): the bound is inclusive, which is whatHandleAsync_TreatsMinimumSimilarityAsInclusiveLowerBoundpins (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/GetContentSimilarityHandlerTests.cs:173). Sorting uses the explicit comparerCompareByScoreThenIndex(: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 isGetRange(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 withMidpointRounding.ToEven(:105), the shared category items resolved to names (:106-108), and at most ten shared keywords (:109). The method ends withResult.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 fromGET 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 ass.Status != SessionStatuses.Declined(:31), a comparison translated to SQL, whereas the sibling handlers compare statuses withOrdinalIgnoreCasein 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 duplicateQuestionId. 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(...)comparinganswers.Select(a => a.QuestionId).ToHashSet().Countagainstanswers.Count, null-tolerant, with the message "Each question may be answered only once per request." (:18-20).RuleForEach(x => x.Answers).ChildRules(...)applyingNotEmpty()to eachAnswerValuewith the message "Answer value is required." (:22-26). All four behaviors are pinned byBatchAddSessionQuestionAnswersCommandValidatorTests(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'sNotEmptytreats 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),TimeProviderandILogger<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-83and:100-103, and inAddSessionQuestionAnswerHandler.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 oneGetAllAsyncwith aContainspredicate (: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 itsSessionQuestionAnswersincluded andasTracking: true(:36-41), because the upsert mutates the loaded graph. A missing session returnsError.NotFounddecorated with source and target (:42-47). It then callsValidateAsync(:49-53) andApply(:55-59), propagating either failure without writing, destructures the applied answers and the created count (:61), issues the singleSaveChangesAsync(: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 runsSessionQuestionAnswerRules.EnsureSessionAcceptsFeedbackonce for the whole form (:82-87), which covers BR-91 (no service sessions), BR-49 (eligible status) and BR-108 (published parent event, with aNotFoundwhen 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 withEnsureAnswerIsValid(: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, callssession.UpdateSessionQuestionAnswer(existing.Id, answer.AnswerValue)(:134); otherwise it callssession.AddSessionQuestionAnswer(null, answer.QuestionId, answer.AnswerValue)(:144) and raises one SessionFeedbackSubmitted on the aggregate carrying the user, session, event, and theTimeProvider-supplied timestamp (:155-156), incrementingcreatedCount(:157). Either domain failure aborts the whole apply pass (:135-138,:145-148). The method deliberately persists nothing; the comment at:115-116records 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 fromPOST SessionQuestionAnswers/batch(:192-210). Behavior is pinned byBatchAddSessionQuestionAnswersHandlerTests(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!.Valueis 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-writeGetRepositoryfor the Event it only reads (:80), unlike the questions read which usesGetReadRepository(: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
QuestionTypefrom "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 noHandleAsyncat all. The load, theNotFound, 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 threeExistsAsynccalls short-circuit as soon as one returns true.[Rubric §1, SOLID]: the aggregate stays ignorant of collections it does not own. - Walkthrough:
sealed partial classwith primary-constructor DI, passingunitOfWorkanddtoMapperup to the base (:19-23). Four overrides follow.EntityId(:26) returnscommand.Id, telling the base which aggregate to load.RowVersion(:32) returnscommand.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 aDbUpdateConcurrencyExceptionand a 412 instead of last-write-wins. The comment above the override records that intent (:28-29), and the base's default returnsnullfor 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 whenentity.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 throughUnitOfWork.GetReadRepository<...>()(the read-only face declared atMMCA.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 returnsError.Validationwith codeQuestion.ImmutableAfterAnswers, an explanatory message, and source and target (:67-74). Past the gate it returnsentity.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]partialLogQuestionUpdateddeclared 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.Validationwith a stable code rather than a thrown exception, so it travels the same Result channel as a FluentValidation failure and the controller's sharedHandleFailureturns it into a client-error response with no special case (MMCA.ADC.Conference.API/Controllers/Questions/QuestionsController.cs:128-129). OverridingMutateAsyncrather thanHandleAsyncis 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 onPUT {id}(:112-132), after which the controller evicts theconference:questionsoutput-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
EventIdentifierTypealias, anintin 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,
EventIdof typeEventIdentifierType(: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 onIQueryHandler<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 injectedIQueryHandler<GetSessionSelectionDashboardQuery, Result<SessionSelectionDashboardDTO>>(:31). That endpoint is the one the UI actually calls: SessionSelectionService requestssessionselection/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
EventIdentifierTypealias (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 sameGetSpeakerSessionOverlapfolder 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 injectedIQueryHandler<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
EventIdand nothing else. - Depends on - the
EventIdentifierTypealias (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, orQueueFull(: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.AlreadyRunningandSessionScoring.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 noNoneorUnknownmember: every path throughTryEnqueuereturns 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 lostTryAddversus a failedTryWrite), 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 bySessionSelectionController.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 aSuccessflag that says whether any of it means anything. - Depends on - the
SessionIdentifierTypealias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:15) and BCLdecimal,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 at0m,Reasoning = "Scoring failed", andSuccess = 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:Reasoningis 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 initmembers (:43-70):SessionId; the sevendecimalscoresOverallScore,TopicRelevanceScore,DescriptionQualityScore,NoveltyScore,ActionableTakeawaysScore,DepthOrInsightQualityScore,CredibilityExperienceScore, each documented as 1.0 to 10.0;Reasoning; andSuccess.requiredon all ten means no partially-populated instance can be constructed, which is what lets the handler readresult.SessionIdrather 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), whoseEnsureScoreInRangerejects anything outside>= 1.0m and <= 10.0mwith aSessionAiScore.OutOfRangeerror (: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 = falseresult carries all-zero scores, whichSessionAiScore.Createwould reject outright. Nothing in the type enforces that pairing; the handler simply never reachesCreateon a failed result because it checksSuccessfirst (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 structmarked[StructLayout(LayoutKind.Auto)](:20-21). - Depends on - the
EventIdentifierTypealias (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8), plusSystem.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
Attempton 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 withAttempt + 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 aChannel<T>avoids a heap allocation per enqueue, andLayoutKind.Autolets the runtime pack the fields. - Walkthrough - two positional members (
:21):EventIdandAttempt.Attemptis 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 asFirstAttempt(SessionScoringQueue.cs:41). - Why it's built this way - a
readonly record structgives 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 byTryEnqueue(:71) andTryRequeue(:97), read by SessionScoringProcessor throughqueue.Reader.ReadAllAsync(SessionScoringProcessor.cs:107). - Caveats / not-in-source - the retry ceiling is not on this type.
MaxAttempts = 3is 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(aGuidin 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, fromspeaker.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
Speakerslist on SessionScoringInput (IAiScoringService.cs:51); constructed only in ScoreEventSessionsHandler (:64). - Caveats / not-in-source - the doc comments state "max 500 chars" for
TagLineand "max 4000 chars" forBio(: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, unrelatedSpeakerInfoexists 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
.Statusis 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 intoPending.[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 noDeclinedmember, because declined sessions are removed before any bucketing happens:CountCategoryItemsfilters them withIsDeclinedfirst (:137,:334-335).ClassifyStatus(:321-332) maps a null status orSessionStatuses.AcceptedtoAccepted,SessionStatuses.AcceptQueuetoAcceptQueue, and everything else toPending, comparing withStringComparison.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) andClassifyStatus(:321-332). Callers receive CategoryDistributionDTO counts, never a bucket value. - Caveats / not-in-source - a second, independent
StatusBucketwith 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 matchingClassifyStatus. Nothing in source keeps the two copies in step. A nullSession.Statuscounts asAcceptedhere (: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, andDeclineQueueall land inPendingwith no way to tell them apart in the output.
StatusBucket
MMCA.ADC.Conference.Application ·
...DecisionSupport.GetCategoryDistributionand...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
.Statusis 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 intoPending.[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 noDeclinedmember, because declined sessions are removed before any bucketing happens:IsDeclinedfilters them out inCountSessionsPerCategoryItem(:45, declared at:114-115).ClassifyStatus(:101-112) maps a null status orSessionStatuses.AcceptedtoAccepted(:103-107),SessionStatuses.AcceptQueuetoAcceptQueue, and everything else toPending(:109-111), comparing withStringComparison.OrdinalIgnoreCasethroughout. - 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.Statuscounts asAcceptedin 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, andDeclineQueueall land inPendingwith 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 theEventIdentifierTypealias (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 theSessionIdentifierTypealias (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).Speakersis 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).SessionIdis 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 byAddHttpClient<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) returnsTask<SessionScoringResult>. There is noResult<T>here and no exception path: the success or failure signal is theSuccessflag on the returned record.ModelId { get; }(:16) exposes which model produced a score. The handler stamps it onto the persisted entity as themodelUsedargument (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 datedyyyy-MM-dd.Nstring, 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
ModelIdandPromptVersionon 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>andSystem.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-blockingTryWrite(:71,:97). That combination means a full queue makesTryWritereturn 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.
TryEnqueueadds to_pendingbefore touching the channel (:68), so of two concurrent duplicate requests exactly one wins theTryAddand the other is refused (:66-69). If the subsequentTryWritefails, 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.
- Refuse rather than drop. The channel is created with
- 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), aConcurrentDictionary<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 theChannelReader<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) returnsAlreadyPendingon a lost claim (:69),Queuedon a successful write (:72), orQueueFullafter 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 thenTryAddSingleton<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-60notes that producers would otherwise write to a queue nobody drains). Producers hold the interface; SessionScoringProcessor holds the concrete class and usesReader,MarkCompleted, andTryRequeue(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
_pendingcheck; 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:MarkCompletedis 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
:33records that the loads stay sequential for EF single-context safety (aDbContextis 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 passesignoreQueryFilters: 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 returnsError.NotFounddecorated with source and target when it is missing (:29-31): this is the only decision-support handler with a failure path, pinned byHandleAsync_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 withSessionSpeakersandSessionCategoryItemsincluded, 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 withSpeakerCategoryItemsincluded 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 fourCountpasses plus one subtraction: accepted counts null-or-Acceptedstatuses, 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, viaCountCategoryItems(:133-156) andBuildCategoryGroups(: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 whoseSessionIdis in the loaded set (:98-104), andBuildAiScoreDtos(:337-360) orders them byOverallScoredescending and projects each one throughBuildSingleAiScoreDto(:362-393). That projection copies the score's provenance straight through,ModelUsedandPromptVersion(: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), andResolveCategoryInfo(: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".ScoredOnprefersLastModifiedOnand falls back toCreatedOn(: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 fromGET 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:
CountCategoryItemsandBuildCategoryGroupshere (:133-156,:158-184) mirror GetCategoryDistributionHandler's equivalents, andComputeSpeakerOverlap(:186-253) mirrors GetSpeakerSessionOverlapHandler'sBuildMultiSessionSpeakers. Nothing in source keeps the copies aligned, and they already differ in one visible way: this handler loads speakers withignoreQueryFilters: 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 emptiesSessionLevel. A session carrying more than one level tag resolves to whicheverResolveCategoryInfoencounters first (:427-430), decided by the enumeration order of the session's links. The handler resolves the read-writeGetRepositoryfor all five entity types (:23-26,:98) although it only reads: IUnitOfWork exposes a narrowerGetReadRepositoryalongside it (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IUnitOfWork.cs:19versus: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 aGetByIdsAsyncover the collected keys (:41-45, the interface atMMCA.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 byHandleAsync_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 withSessionSpeakersandSessionCategoryItemsincluded andasTracking: 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 withSpeakerCategoryItemsincluded (:41-45) and all categories with their items (:47-50), then builds two lookups: the locality lookup, viaSpeakerLocalityHelper.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), computesHasAcceptedSessionby treating a null status orSessionStatuses.Acceptedas accepted withOrdinalIgnoreCase(:111-113), stamps the locality tier (:119), and orders each speaker's sessions by title case-insensitively (:122). Each session becomes a SpeakerSessionSummary throughBuildSessionSummary(: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 withOrdinalIgnoreCase, 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 fromGET 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'sGetByIdsAsynccall does not passignoreQueryFilters(:41-45, versusGetSessionSelectionDashboardHandler.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.LocalityCategorystays 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.
SaveChangesAsyncis 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-103records 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 onSessionIdin 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).
- Commit per session, not per run.
- Walkthrough
- Repositories for Session and SessionAiScore off the unit of work (
:27-28). - Load the event's sessions with
SessionSpeakersincluded, 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, oneGetByIdsAsync(: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); callScoreSessionAsync(:70). - Two failure gates before any write.
!result.Successcounts a failure and continues (:72-77).SessionAiScore.Createreturning 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. TheCreatecall passes both provenance values off the port,aiScoringService.ModelIdandaiScoringService.PromptVersion(:79-83), so the persisted row names the model and the prompt contract that produced the number. - The write step (
:92-117):ExecuteDeleteAsyncfor this session's existing score rows, accumulating the count intoreplaced(:105);AddAsyncfor the new entity (:107);SaveChangesAsync(:108). It is wrapped incatch (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 aResultfailure with codeAiScoring.AllFailednaming the likely cause (:127-133). Any partial success returnsResult.Successwith the counts, which is what keeps the drain worker from retrying a business outcome.
- Repositories for Session and SessionAiScore off the unit of work (
- 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
AllFailedfailure 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 byScoreEventSessionsHandlerTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/DecisionSupport/ScoreEventSessionsHandlerTests.cs:13). - Caveats / not-in-source -
ExecuteDeleteAsyncis 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 flaggedIsDeleted. Scoring is also strictly sequential, one Anthropic call at a time with no concurrency knob, so wall-clock time grows linearly with session count. Thereplacedcounter is logged (:138-139) but is not part of ScoreEventSessionsResultDTO, which carries onlySessionsScoredandSessionsFailed.
[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
.icsdocument covering every exportable session on that event's schedule. - Depends on: the
EventIdentifierTypealias, anintin 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 wantstext/calendarbytes, so the use case produces the serialized document and the controller only wraps it in aFile(...)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 theEventsCacheoutput-cache policy (EventsController.cs:154-165), resolved through the injectedIQueryHandler<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
.icsdocument, the one behind the "add to calendar" button on a session page. - Depends on: the
SessionIdentifierTypealias, anintin 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 oneExportCalendarfolder, 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 theSessionsCachepolicy (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:261-272), through the injectedIQueryHandler<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'sGetReadSpecificationAsyncoverride (SessionCategoryItemsController.cs:73-83), which returnsnullfor 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'sGetReadSpecificationAsyncoverride (SessionsController.cs:77-87), which short-circuits tonullfor privileged readers (:79-80). That hook feeds the unpaged list (:148), the paged list throughBuildPagedSessionSpecificationAsync(: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 fromEntityTypeConfigurationSQLServer<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'sGetReadSpecificationAsyncoverride (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
SpeakerIdentifierTypealias (System.Guidin 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
SpeakerIdas an ordinary filter key, but Session has noSpeakerIdcolumn: 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 theSessionaggregate 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 givingSessiona navigation toSpeaker.[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 inBuildPagedSessionSpecificationAsyncafter theSpeakerIdkey 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-104states 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.Globalizationand BCLTimeZoneInfo. - 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. IsExportabledelegates the status question wholesale toSessionStatuses.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.
- IcsCalendarBuilder is UTC-only by contract so it can emit
- Walkthrough
ProductId = "-//MMCA//AtlDevCon//EN"(:17), the RFC 5545 PRODID stamped on every ADC-produced calendar document.IsExportable(Session)(:26-28): a property pattern requiringStartsAtandEndsAtto be non-null andIsServiceSessionto be false, combined withSessionStatuses.IsEligible(session.Status)(BR-49: onlyAcceptedor 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'sVenueAddresswith ", ", dropping blanks (:33-35), then builds the IcsEvent with a stable uid of the formsession-{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 asUnspecified(: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 theDateTimeOffsetwith 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 staticclass 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 fromTimeZoneInfo.GetUtcOffset's own behavior for an ambiguous local time (:55), not from a decision in this file.IsExportableignores 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 overResult<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; anINover materialized ids translates on every provider, which is what ADR-018 needs. The framework enforces the same constraint mechanically for declared specification classes through theSpecificationsDoNotNavigateToOtherEntitiesfitness rule, which instantiates parameterless specifications and inspects theirCriteria(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 theSessionaggregate keeps its by-id boundary to the Speaker aggregate even while answering a cross-aggregate question. - Walkthrough
- Project the linked session ids (
:30-37): resolve the read repository forSessionSpeakerand callGetProjectedAsync(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;asTrackingis passed explicitly even thoughfalseis already its default (IRepository.cs:108), and a tracked read would pollute the change tracker for whatever else the request does.ignoreQueryFiltersis left at itsfalsedefault (IRepository.cs:109), so soft-deleted join rows are excluded by the EF global query filter rather than by a predicate term written here. - 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 toIN. A lazily-enumerated source would be captured unevaluated and re-enumerated every time the criteria is applied. - Wrap and return (
:42-43):ids.Contains(s.Id)becomes an InlineSpecification<TEntity, TIdentifierType> returned insideResult.Success. There is no failure path: the handler cannot fail on its own terms.
- Project the linked session ids (
- 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 emptyINmatches 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
BuildPagedSessionSpecificationAsyncis 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, theDistinct()at:40). - Caveats / not-in-source: the id list is materialized into the predicate, so the
INlist 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
.icsdocument: 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 implementsIQueryHandler<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 notryand 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 returnsError.NotFoundtagged 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
IsPublishedreturns theNotFounderror (: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,asTrackingdefaulting tofalseat: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 byStartsAt(:45), map each session throughToIcsEvent, passing the room name only when the session has aRoomIdthe dictionary resolves (:46-50). - Build and return:
IcsCalendarBuilder.Build(CalendarExportMapper.ProductId, entries, DateTimeOffset.UtcNow)wrapped inResult.Success(:53-54).
- Load the Event by id with
- 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
StartsAtbefore 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 fromGET Events/{id}/ics(:160), which returns the string as atext/calendarfile namedevent-{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 asNotFound(:61), an unpublished event asNotFound(: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.UtcNowis 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 whoseRoomIdis 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 throwsTimeZoneNotFoundExceptionout 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
.icsdocument. - Depends on: the same set as its event-wide twin: IUnitOfWork (
:16-17), CalendarExportMapper, IcsCalendarBuilder, Session, Event, Result / Error. It implementsIQueryHandler<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
NotFoundfor 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 sameIsExportablepredicate 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
Roomsincluded viasession.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).
- Load the session by id, no includes (
- 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
IcsEventmapping, 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 fromGET Sessions/{id}/ics(:267), which returnstext/calendarnamedsession-{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.UtcNowis 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 staticStatusCriteria); 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.IsPublishedis not translatable when principal and dependent may live in different physical sources (MMCA.Common/Source/Core/MMCA.Common.Application/Specifications/CrossSourceSpecification.cs:9-21).BuildAsyncruns a scalar projection against the principal's own source (:54-57), materializes the keys once (:59-60), then buildsEnumerable.Contains(keys, dependent.ForeignKey)as an expression tree (:74-79) and ANDs the optional local predicate onto it after rebinding its parameter, deliberately avoidingExpression.Invokeso 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'sStatusCriteria(: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 iss => s.Status == null || s.Status == SessionStatuses.Acceptedand compares againstSessionStatuses.Acceptedrather than calling SessionStatuses'sIsEligible, 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
- Build the cross-source specification (
:29-36): one call toCrossSourceSpecification.BuildAsync<Session, SessionIdentifierType, Event, EventIdentifierType>withprincipalPredicate: e => e.IsPublished(BR-108),dependentForeignKey: s => s.EventId, andlocalPredicate: PublicSessionStatusSpecification.StatusCriteria(BR-49). The type arguments pin the direction of the relationship:Sessionis the dependent being filtered,Eventthe principal being resolved. - Return (
:38):Result.Success(specification). As with its siblings there is no failure branch.
- Build the cross-source specification (
- 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
GetReadSpecificationAsyncoverride (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 overAcceptedandnull), 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:
CrossSourceSpecificationmaterializes 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 failedResulttonull, 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> toResult<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 intoContains, not a navigation join, so the criteria stays translatable on any provider (ADR-018). - Walkthrough
- Resolve the visible session ids (
:25-27):PublicConferenceVisibility.GetVisibleSessionIdsAsync(unitOfWork, cancellationToken). Inside the resolver that is the sameCrossSourceSpecification.BuildAsynccall GetPublicSessionFilterHandler makes, over Session and Event withprincipalPredicate: e => e.IsPublished,dependentForeignKey: s => s.EventId, andlocalPredicate: 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 atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:279-282and 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 itsCriteriaand nothing else, so this is the untracked, soft-delete-filtered read the explicitGetProjectedAsyncarguments would otherwise have to spell out. The comment at:61-62states 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. - Wrap and return (
:29-31):sci => sessionIds.Contains(sci.SessionId)inside an InlineSpecification<TEntity, TIdentifierType>, returned asResult.Success. No failure path.
- Resolve the visible session ids (
- 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
GetReadSpecificationAsyncoverride (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:GetProjectedAsyncon theEventrepository (:29-36) and the spec-takingListAsyncon theSessionrepository (: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.SessionIdis 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 usesdefaultas its row session id (:20-21). - Caveats / not-in-source: as on the other junction reads, the controller maps a failed
Resulttonull, 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 theINlist 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 aContainsover 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
- 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-takingListAsyncoverload (:75-79). - Wrap and return (
:28-30):ss => sessionIds.Contains(ss.SessionId)inside an InlineSpecification<TEntity, TIdentifierType>, returned asResult.Success. The handler has no failure branch and no conditional logic at all.
- Resolve (
- 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
GetReadSpecificationAsyncoverride (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
Resultbecomesnullin 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 recordwith 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
GetReadSpecificationAsyncoverride (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(intfor Conference, declared inMMCA.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 asnew 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 aSpeaker.Id IN (...)criteria, following the BR-132 precedent. That keeps the criteria free of navigation joins, so it stays translatable on any engine andSpeakerkeeps 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
stringandstring?, 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
Idand implementsICreateRequestandICacheInvalidating,MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequest.cs:11; update carries onlyEventIdplus 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 declaredwhere T : ISessionFieldsRequestand can therefore writep => p.Titleonce 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:
Titleis non-nullable (:16) and is the only required member;Description(:19),Status(:22),LiveUrl(:25),RecordingUrl(:28),AccessibilityInfo(:31), andResourceLinks(:34) are allstring?. Every member is get-only, so implementing it costs aninit-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:EventIdis 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). LikewiseStartsAt,EndsAt, andRoomIdare 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 growingWhen(...)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 aliasEventIdentifierType. - 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 genericAbstractValidator<T>subclass whose constructor takes the property selector; a concrete validator then folds it in with FluentValidation'sInclude(...). Because the fragment is generic inT, 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) forwardsbase(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 singleNotEmpty()clause with the messageYou 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. BecauseEventIdentifierTypeisint(MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:8), FluentValidation'sNotEmptytests againstdefault(int), so the rule rejects a missing or zero event id; the base's remarks (CommonValidationRules.cs:133-139) note that the same check rejectsGuid.Emptyfor 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 theSession.EventId.Immutableerror (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) anddefault(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 oneIncludeof 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 appliesMaximumLengthand noNotEmpty(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) forwardsbase(selector, "Accessibility Info", SessionInvariants.AccessibilityInfoMaxLength)(:90). The label "Accessibility Info" is what appears in the generated length message; the bound is500, reached through SessionInvariants (MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:25) which itself forwards toSessionDTO.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 ownEnsureStringMaxLengthguard (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,
MaximumLengthonly, noNotEmpty. - Walkthrough: the constructor (
:39) forwardsbase(selector, "Session Description", SessionInvariants.DescriptionMaxLength)(:40);DescriptionMaxLengthis4000(MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:21, re-exported atMMCA.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.TryCreatecheck, 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 absolutehttp/httpscheck on top of the same length bound (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:85-94), so choosingOptionalStringRules<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 acceptsjavascript:anddata:values, so anything that renders this field as a link owns its own scheme checking. - Walkthrough: the constructor (
:64) forwardsbase(selector, "Live URL", SessionInvariants.LiveUrlMaxLength)(:65);LiveUrlMaxLengthis2000(MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:33, re-exported atMMCA.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) forwardsbase(selector, "Recording URL", SessionInvariants.RecordingUrlMaxLength)(:78);RecordingUrlMaxLengthis2000(MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:36, re-exported atMMCA.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) forwardsbase(selector, "Resource Links", SessionInvariants.ResourceLinksMaxLength)(:102);ResourceLinksMaxLengthis2000(MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:30, re-exported atMMCA.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.
Statusdrives 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) forwardsbase(selector, "Session Status", SessionInvariants.StatusMaxLength)(:52);StatusMaxLengthis100(MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:24, re-exported atMMCA.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()thenMaximumLength(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) forwardsbase(selector, "Session Title", SessionInvariants.TitleMaxLength)(:17).TitleMaxLengthis500(MMCA.ADC.Conference.Shared/Sessions/SessionDTO.cs:18, re-exported atMMCA.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 stableSession.<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'sAbstractValidator<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'sIncludeflattens 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-bodiedInclude(MMCA.ADC.Conference.Application/Sessions/UseCases/Update/SessionUpdateRequestValidator.cs:12), and SessionCreateRequestValidator is that sameIncludeplus 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 oneIncludehere, not two edits kept in sync by discipline. - Walkthrough: the class declaration (
:111-113) issealed class SessionFieldRules<T> : AbstractValidator<T> where T : ISessionFieldsRequest. The parameterless constructor (:115-124) makes sevenIncludecalls 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 againstTand 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 ofWhen(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 theIncludechain 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, plusSystem.Linq.Expressions(BCL) and the module aliasesRoomIdentifierTypeandSessionIdentifierType(bothint). - 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-guardsparentEvent(: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'sRoomscollection, requiring it to be non-soft-deleted (:59); a room that belongs to some other event is not found there and returnsError.ValidationcodedSession.RoomId.CrossEventtargetingSession.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 theExpression<Func<Session, bool>>the probe translates to SQL.excludeSessionIddefaults tonulland collapses toint.MinValue(:101), which the comment justifies: session ids are always positive (Sessionize-assigned or the reserved manual range starting at999_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 sameRoomId,Id != exclusionId, both timestamps non-null, and then the two strict comparisonss.StartsAt < endsAt && s.EndsAt > startsAtthat define the half-open overlap.DoubleBookedError(:116-121) returnsError.ConflictcodedSession.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 passexcludeSessionIdso 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 assertsDoubleBookedErroris 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 withexcludeSessionId: command.Id(MMCA.ADC.Conference.Application/Sessions/UseCases/Update/UpdateSessionHandler.cs:66-74), each passing its own handler name as the errorsource. Both load the parent event withincludes: [nameof(Event.Rooms)]andasTracking: falsefirst (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) isResult<Specification<SpeakerCategoryItem, SpeakerCategoryItemIdentifierType>>.HandleAsync(:22-24) callsPublicConferenceVisibility.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 issci => speakerIds.Contains(sci.SpeakerId)(:31-32), aSpeakerId 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
SpeakerCategoryItemand theSessionandEventaggregates the rule actually reads. Thequeryparameter 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 itsGetReadSpecificationAsyncoverride (:73-83), the framework's read hook: it returnsnullfor 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), coveringGetAll(:85), the paged list (:95), the lookup (:114), andGetById(:122), each[AllowAnonymous](:86,:96,:115,:123) under the class-level[HasPermission(ConferencePermissions.SpeakersManage)]requirement (:47). Registration is convention-based: the module'sScanModuleApplicationServices<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:Speakerkeeps a by-id relationship toSessionandEventinstead 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) passesquery.EventIdstraight through toPublicConferenceVisibility.GetVisibleSpeakerIdsAsync(unitOfWork, query.EventId, cancellationToken)(:26-28), then returnsResult.Successover an InlineSpecification<TEntity, TIdentifierType> with the criterias => 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 theEventSpeakerjoin 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 fromBuildPublicSpeakerSpecificationAsync(:84-95), which returnsnullfor privileged readers so Organizers and ContentEditors see every speaker (:65,88-89). The controller'sGetReadSpecificationAsyncoverride calls that helper witheventId: null(:104-106), which is the rule spanning every published event, and it backs the unpaged list (:108), the lookup (:207,214), andGetById(: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(aSystem.Guid) andSessionIdentifierType(anint), declared inMMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19and: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 namingSpeakerIdas "the speaker requesting the count" (:4) andSessionIdas 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:449for theGET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/countendpoint (: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
SpeakerIdentifierTypeandSessionIdentifierType(MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19,15), used at:7-8. The only external is BCLIReadOnlyCollection<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:SessionIdsis 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) andSessionIds(:8). The declared parameter type isIReadOnlyCollection<SessionIdentifierType>, so the handler can cheaply testCountbefore 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 bindssessionIdsfrom 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
SpeakerIdentifierTypeandSessionIdentifierType(: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
Forbiddenerror 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 recordwith two positional parameters,SpeakerIdandSessionId(: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 theOrganizerrole or carry the route speaker'sspeaker_idclaim, otherwise it returnsForbid()(: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
EventIdentifierTypealias (anint,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 noEventIdcolumn, 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 thatSpeakerhas noEventIdcolumn. - Why it's built this way: keeping Speaker free of an
EventIdcolumn 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
EventIdout of the incoming filter dictionary and removes it unconditionally, becauseSpeakerhas 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
Tand 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> constrainswhere T : ISpeakerFieldsRequest(SpeakerValidationRules.cs:115) and writes the six selectors itself (:119-124), so a concrete validator is reduced to oneInclude. The interface is deliberately narrower than either request record: the create request'sFullNamestays 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 oneIncludein 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:
FirstNameandLastNameare non-nullablestring(:15,18);Email,LinkedInUrl,GitHubUrlandWebsiteUrlarestring?(: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 anExpression<Func<T, string>>(SpeakerValidationRules.cs:41) whileEmailhere isstring?, so SpeakerFieldRules<T> passesp => p.Email!with the null-forgiving operator (:121). It is safe because the rule body only runs inside aWhenclause 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, alongsideICreateRequestandICacheInvalidating) 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,ProfilePictureandTwitterHandle(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, plusSpeakerDTO.BioMaxLengthatMMCA.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), andSystem.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 chainsNotEmpty,EmailAddressandMaximumLengthin 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 dropNotEmpty. This fragment does the opposite: it keeps the framework rule intact and gates the entireIncludebehind aWhenpredicate (: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: theWhenpredicate needs a value, not an expression tree, so the constructor callsselector.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);EmailMaxLengthis255(MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:27, surfaced throughSpeakerInvariants.cs:22). No error code is supplied, so the failures carry FluentValidation's default codes rather than a stableSpeaker.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 passesp => 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), andExpression<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()thenMaximumLength(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) forwardsbase(selector, "First Name", SpeakerInvariants.FirstNameMaxLength)(:16).FirstNameMaxLengthis200(MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:21, re-exported bySpeakerInvariants.cs:16), the same constant the aggregate's own guard cites when it builds theSpeaker.FirstName.TooLongfailure (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 selectorp => p.FirstName(:119). - Caveats / not-in-source: neither this fragment nor any other in the file supplies the optional
errorCodeargument the framework bases accept (CommonValidationRules.cs:43,55,66,87), so request-level failures surface with FluentValidation's default codes, not the stableSpeaker.<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
httporhttpsURL. - Depends on:
AbstractValidator<T>directly (:77), the framework's AbsoluteUrlRules<T> (:84), SpeakerInvariants (:84), andExpression<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:anddata:values, which become executable the moment something renders them as a link href or an image src. AbsoluteUrlRules<T> closes that by chainingMaximumLengthwithMust(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 behindWhen(x => !string.IsNullOrWhiteSpace(accessor(x)), ...)(:83), exactly the optionality mechanism taught at SpeakerEmailRules<T>. Inside, it includesnew AbsoluteUrlRules<T>(selector, "GitHub URL", SpeakerInvariants.GitHubUrlMaxLength)(:84);GitHubUrlMaxLengthis2000(MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:49, viaSpeakerInvariants.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 selectorp => p.GitHubUrl(:123). - Caveats / not-in-source: the rule constrains the scheme and the length, not the host, so an absolute
httpsURL 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) forwardsbase(selector, "Last Name", SpeakerInvariants.LastNameMaxLength)(:27).LastNameMaxLengthis200(MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:24, viaSpeakerInvariants.cs:19), the same constant the aggregate guard cites forSpeaker.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> withp => 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
httporhttpsURL 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 executablejavascript:ordata:target out of a rendered link (:50-55). - Walkthrough: the constructor (
:60) compiles the selector (:62), gates on a non-blank value (:64), and includesnew AbsoluteUrlRules<T>(selector, "LinkedIn URL", SpeakerInvariants.LinkedInUrlMaxLength)(:65);LinkedInUrlMaxLengthis2000(MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:46, viaSpeakerInvariants.cs:34). - Where it's used:
Included by SpeakerFieldRules<T> withp => 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 includesnew AbsoluteUrlRules<T>(selector, "Website URL", SpeakerInvariants.WebsiteUrlMaxLength)(:103);WebsiteUrlMaxLengthis2000(MMCA.ADC.Conference.Shared/Speakers/SpeakerDTO.cs:52, viaSpeakerInvariants.cs:40). - Where it's used:
Included by SpeakerFieldRules<T> withp => 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
Includecalls, 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:115guaranteesTexposes 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 oneIncludehere, 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 sixIncludecalls in field order (:119-124). Two details are worth noticing. First,Includeon 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 writtenp => p.Email!(:121) because SpeakerEmailRules<T> takes a non-nullable selector while the interface property isstring?; the fragment's ownWhenguard 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 oneIncludeof 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 isrequiredon 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 fromMMCA.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
Sharedproject, 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 withTryAddScoped(MMCA.ADC.Engagement.Application/DependencyInjection.cs:46); in the extracted topology the Conference service host callsAddEngagementBookmarkCountClient()(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:350), which registers the typed gRPC client and then usesservices.Replaceso the adapter wins over whatever is already in the container (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/DependencyInjection.cs:43-51, adapter atMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Contracts/BookmarkCountServiceGrpcAdapter.cs:14); and when the Engagement module is switched off entirely the module'sRegisterDisabledStubshook 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-injectingIRepository<,>directly) and loads the session by id with itsSessionSpeakersincluded andasTracking: false(:24-28), since this is a pure read. A missing session returnsError.NotFoundstamped with the handler name and target (:29-30). The authorization step (:33) then requires at least one non-soft-deletedSessionSpeakerwhoseSpeakerIdmatches the caller, and otherwise returnsError.ForbiddencodedSpeaker.NotAssignedwith a message, source and target (:35-40). Only after both checks does it callbookmarkCountService.GetBookmarkCountForSessionAsync(query.SessionId, cancellationToken)(:43) and wrap the integer inResult.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.IsDeletedtest at:33is 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 theGET /Speakers/{speakerId}/sessions/{sessionId}/bookmarks/countendpoint (:439-454), which is[AllowAnonymous](:440) and served through theBookmarkCountsCacheoutput-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 itsSessionSpeakersnavigation (: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 returnForbiddenon 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 implementsIQueryHandler<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 theirSessionSpeakerseager-included,asTracking: false(:34-38). The authorization projection (:43-46) keeps sessions where anySessionSpeakermatches the query'sSpeakerIdand is not soft-deleted, and selects just the ids. A second short circuit returns an empty dictionary when nothing survived (:48-52). Finally it callsbookmarkCountService.GetBookmarkCountsForSessionsAsync(authorizedSessionIds, ...)(:54-56) and wraps the returned dictionary inResult.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 to0(MMCA.ADC.Engagement.Shared/UserSessionBookmarks/IBookmarkCountService.cs:22-23). Note the explicit!ss.IsDeletedtest 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 thebookmarks/countsendpoint (:467-469), which is[AllowAnonymous]and served through theBookmarkCountsCacheoutput-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 inMMCA.ADC.Conference.Shared/Speakers/SessionFeedbackDTO.cs:6,22,38), Result and Error (:7), and BCLSystem.Globalizationfor 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 returnsError.Forbiddenwith the stable codeSpeaker.NotAssignedplus 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 storesAnswerValueas a string regardless of question type, so aRatinganswer must be parsed back to an integer. The handler usesint.TryParsewithNumberStyles.IntegerandCultureInfo.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 withSessionSpeakersandSessionQuestionAnswerseager-included andasTracking: false(:24-28), returningError.NotFoundsourced 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 aHashSet(: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 onquestion.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 withAverageRatingandResponseCount(: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 inResult.Success(:103-109). - Why it's built this way: the comment at
:42records 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.IsDeletedon 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 useGetRepositoryrather thanGetReadRepository(:23,57), though both passasTracking: 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
GetProjectedAsyncon the read repository up to three times, each pulling a single scalar column with awhereclause andasTracking: 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> overspeakerIds.Contains(s.Id)(:52-53), which EF translates to a SQLIN. 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 anEventIdcolumn. - Walkthrough: the primary constructor takes only the unit of work (
:19-20); the handler'sTResultisResult<Specification<Speaker, SpeakerIdentifierType>>(:21).HandleAsync(:24-26) runs the direct-link projection first (:28-31), then the event's session ids (:33-36).sessionSpeakerIdsis 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 anIReadOnlyList<T>(:42) with an explanatory comment (:41): the predicate must close over a stable collection for EF to translate it intoINrather 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 writesSessionSpeaker), 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 theAndextension, 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 recordwith 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 realEventIdcolumn (MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:45), so the rule resolves to a published-event id list and returns as aSponsor.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 scalarINis 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
GetReadSpecificationAsyncoverride (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?, andint(MMCA.ADC.Conference.Application/Sponsors/Validation/ISponsorFieldsRequest.cs:15-36); the file has nousingdirectives 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> constrainswhere 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), andBoothNumber(: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 takestring?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
ICreateRequestandICacheInvalidating(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, andintare 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 mistypedEventIdcannot 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 HTTPIf-Matchheader 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 onerequiredmember, so the model binder rejects a body without it before any validator runs.Description(:16) is optional.StartTimeandEndTime(:19,:22) are plainDateTimevalues documented as event-local (:18,:21).VenueName(:25) is optional and documented such that empty means the main conference venue, withVenueAddress(:28) andVenueUrl(:31) alongside it for off-site items.SortOrder(:34) breaks ties between activities that start at the same time (:33). Every member isinit-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 onlyNameasrequiredpushes 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 intoUpdateEntityCommand<Activity, ActivityUpdateRequest, ActivityIdentifierType>with theIf-Matchtoken (:193-195), validated by ActivityUpdateRequestValidator through the command-to-request validator bridge, and applied field by field by ActivityUpdateApplier. It is also theTUpdateRequestargument 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 theEventIdentifierTypealias (: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 aMMCA.Commonvalidation base, whose constructor takes anExpression<Func<T, TProperty>> selectorand 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'sInclude(...), 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 declaresRuleFor(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,NotEmptyon an identifier is the deliberate check, rejecting0for an integer key andGuid.Emptyfor aGuidkey, 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".WithErrorCodeis contract, not decoration:Sponsor.EventId.Requiredis 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'sCreatecomposes 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) andExpression<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 constantBoothNumberMaxLength(: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 classwhose 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 writenew 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.BoothNumberMaxLengthis itself= SponsorDTO.BoothNumberMaxLength(MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:34), and the literal50is 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 takesExpression<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.EnsureBoothNumberIsValiddelegates toCommonInvariants.EnsureOptionalStringMaxLengthwith the same constant and the error codeSponsor.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 throughSponsorInvariants.EnsureBoothNumberIsValidon bothCreateandUpdate(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.DescriptionMaxLengthatMMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:22, literal 2000 atMMCA.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-65declares 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> fromMMCA.Common.Application.Validation(:4,82), and SponsorInvariants.LinkedInUrlMaxLength(:3,82,= SponsorDTO.LinkedInUrlMaxLengthatSponsorInvariants.cs:28, literal 2000 atSponsorDTO.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 inWhen(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'sMust(BeAnAbsoluteHttpUrl)delegates toCommonInvariants.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 acceptsjavascript:anddata: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 takesExpression<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:Includeinside aWhenblock 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
Matchesregex 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.LogoUrlMaxLengthatSponsorInvariants.cs:19, literal 2000 atSponsorDTO.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 acceptedjavascript:ordata: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-35compiles the selector (:31) and registersWhen(...)aroundInclude(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.EnsureLogoUrlIsValidappliesEnsureOptionalStringMaxLengthwith the error codeSponsor.LogoUrl.TooLongand 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 throughSponsorInvariants.EnsureLogoUrlIsValidon bothCreateandUpdate(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.NameMaxLengthatSponsorInvariants.cs:16, literal 200 atSponsorDTO.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 ofMaximumLength(...)and takes a non-nullableExpression<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 astring?property to this rule set will not compile, which is also why ISponsorFieldsRequest declaresNameas 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 takesExpression<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, soWithOptionalErrorCodeleaves 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.EnsureNameIsValidin bothCreateandUpdate(MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:120,166), which returns Result errors carrying the stable codesSponsor.Name.EmptyandSponsor.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.TwitterHandleMaxLengthatSponsorInvariants.cs:31, literal 100 atSponsorDTO.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.WebsiteUrlMaxLengthatSponsorInvariants.cs:25, literal 2000 atSponsorDTO.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 theWhenguard 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
Tis 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 extraIncludeon the create side with the reason written beside it (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestValidator.cs:11-15versusMMCA.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 oneIncludehere, 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 eightInclude(new XRules<T>(p => p.Field))statements in the order name, sort, logo URL, description, website URL, LinkedIn URL, Twitter handle, booth number. FluentValidation'sIncludemerges 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
Includelines 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:ScanModuleApplicationServicescalls FluentValidation'sAddValidatorsFromAssembly(moduleAssembly)(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:252, invoked atMMCA.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
Updatemethod. 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 fromMMCA.Common.Shared.Abstractions(:3,20), and theActivityIdentifierTypealias (: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 bindsICommandHandler<UpdateEntityCommand<Activity, ActivityUpdateRequest, ActivityIdentifierType>, Result<ActivityDTO>>to the framework's UpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest> withTryAddsemantics (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 injectedIEntityUpdateApplier<...>(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:ScanModuleApplicationServicesscans the module assembly forIEntityUpdateApplier<,,>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-memoryActivityand 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 returnsTask.FromResult(entity.Update(...))passing the eight request members positionally (:25-33). Everything downstream of that call belongs to the domain:Activity.Updatecombines 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 raisesActivityChangedwithDomainEntityState.Updated(:173). The refusal travels back as a Result, and the handler's base workflow stops the write beforeSaveChangesAsync. The<remarks>records the deliberate omission: the request carries noEventId, 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 asICommandHandler<UpdateEntityCommand<Activity, ActivityUpdateRequest, ActivityIdentifierType>, Result<ActivityDTO>>(MMCA.ADC.Conference.API/Controllers/Activities/ActivitiesController.cs:43) and invokes fromPUT {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
Includeof 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> fromMMCA.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 plusActivityEventIdRules<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 theUseCases/Updatefolder 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 constrainedwhere 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.
ScanModuleApplicationServicescallsAddValidatorsFromAssembly(moduleAssembly)(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:252, invoked atMMCA.ADC.Conference.Application/DependencyInjection.cs:133), so this validator is registered asIValidator<ActivityUpdateRequest>.AddEntityCrudthen registers a CommandRequestValidator<TCommand, TRequest> bridgingUpdateEntityCommand<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]: theINpredicate is engine-portable per ADR-018. - Walkthrough: the primary constructor takes only the unit of work (
:16-17); the class implementsIQueryHandler<GetPublicSponsorFilterQuery, Result<Specification<Sponsor, SponsorIdentifierType>>>(:18).HandleAsync(:21-23) awaitsPublicConferenceVisibility.GetPublishedEventIdsAsync(unitOfWork, cancellationToken)(:25-27), which reads throughIUnitOfWork.GetReadRepositoryand projectsEvent.IdwhereIsPublishedwithasTracking: 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 returnsResult.Successwrapping anInlineSpecification<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 itsGetReadSpecificationAsyncoverride (:68-78), which is the framework's single read hook: it short-circuits tonullfor 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
intproperty the caller points it at. - Depends on:
NonNegativeIntRules<T>fromMMCA.Common.Application.Validation, which it derives from (ConferenceCategoryValidationRules.cs:5,42), andExpression<Func<T, int>>fromSystem.Linq.Expressions(:2,44). FluentValidation'sAbstractValidator<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
Twhose constructor takes an expression naming the property to check. That is what lets one rule serve unrelated request shapes:AddCategoryItemCommandandUpdateCategoryItemCommandshare no base type, yet both handp => p.Sortto this class. Three specialization styles sit in this one file.ConferenceCategoryTitleRules<T>andCategoryItemNameRules<T>write theirRuleForchain 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"andWithOptionalErrorCode(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 notGreaterThan(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) andUpdateCategoryItemCommandValidator(MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:12). Those are the only two binding sites in the module; both commands declareSortas a positionalint(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:
IEventFieldsRequestfromMMCA.ADC.Conference.Application.Events.Validation, which it implements (EventUpdateRequest.cs:1,7), and theQuestionModerationDefaultenum fromMMCA.ADC.Conference.Shared.Events(:2,37). Every other member is a BCL primitive, includingDateOnlyfor the two dates (:16,19). - Concept introduced: the precondition is not in the body. This record carries no
RowVersionmember at all. The caller's last-observed optimistic-concurrency token travels in the HTTPIf-Matchheader, is decoded bySupportsIfMatchAttributeinto the request'sItemsbag, and is read back by the action throughSupportsIfMatchAttribute.RequiredToken(HttpContext)(MMCA.Common/Source/Presentation/MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:68-76; the call site isMMCA.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
DateOnlyvalues (:16,19) and itsTimeZoneis a string documented as an IANA identifier (:21-22). Nothing here is aDateTimeOffset, 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, seeUpdateEventHandler).[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 byEventTimeZoneRules<T>(MMCA.ADC.Conference.Application/Events/Validation/EventValidationRules.cs:25,41,44). - Walkthrough: four members are
requiredand therefore cannot be omitted by a caller:Name(:10),StartDate(:16),EndDate(:19) andTimeZone(:22). The optional strings areDescription(:13),SessionizeCode(:25, the code tying this edition to a Sessionize event feed),VenueAddress(:28),VenueMapUrl(:31),WiFiInfo(:34),OrganizerContactEmail(:40),SponsorshipPacketUrl(:43) andTicketingUrl(:46).QuestionModerationDefault(:37) is the one enum member, documented as the BR-233 moderation default for live-layer session questions. Every member isinit-only, and seven of them satisfy the get-only contract ofIEventFieldsRequest(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.
EventCreateRequestimplements the same interface (MMCA.ADC.Conference.Application/Events/UseCases/Create/EventCreateRequest.cs:11) but has noQuestionModerationDefaultmember, andEventCreateRequestMapperskips 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 defaultPending(MMCA.ADC.Conference.Domain/Events/Event.cs:181; enum members atMMCA.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
EventsControlleronPUT {id}(MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:220-228), validated byEventUpdateRequestValidator, wrapped together with theIf-Matchtoken inUpdateEventCommand(:232), and passed field by field intoEvent.UpdatebyUpdateEventHandler(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:
ISessionFieldsRequestfromMMCA.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 (EventIdentifierTypeat:9,RoomIdentifierTypeat:51), so the record pulls in no domain type at all. LikeEventUpdateRequestit has noRowVersionmember: the precondition arrives in theIf-Matchheader (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.
EventIdisrequiredhere (: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 byUpdateSessionHandler, which compares the request value against the loaded entity and fails with an unprocessable-entity error codedSession.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) andTitle(:12).Description(:15) is optional. The schedule is two nullableDateTimevalues,StartsAt(:18) andEndsAt(:21), so an unscheduled session is a legal state.Status(:24) is a free-form optional string. Four booleans describe the slot:IsInformed(:27) andIsConfirmed(:30) track the speaker-communication workflow,IsServiceSession(:33) marks lunch and break blocks, andIsPlenumSession(:36) marks whole-room slots. Four optional strings follow:LiveUrl(:39),RecordingUrl(:42),AccessibilityInfo(:45) andResourceLinks(:48).RoomId(:51) is the nullable room assignment, and it is the one member that triggers cross-aggregate work in the handler. Every member isinit-only; seven of them satisfyISessionFieldsRequest(MMCA.ADC.Conference.Application/Sessions/Validation/ISessionFieldsRequest.cs:13-35), whose remarks record whyEventIddeliberately stays off that interface (:8-12). - Why it's built this way: nullable
StartsAt/EndsAtare 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;SessionRoomSchedulingreads 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 intoSession.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
SessionsControlleronPUT {id}(MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:319-328), validated bySessionUpdateRequestValidator, wrapped inUpdateSessionCommand(:331), and consumed field by field byUpdateSessionHandler.
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
UpdateEventHandlerreturns: the updatedEventDTOplus a boolean saying whether this particular update changed the time zone while sessions already existed. - Depends on:
EventDTOfromMMCA.ADC.Conference.Shared.Events(UpdateEventCommand.cs:2,25). Nothing else; the second member is abool. - Concept introduced: the advisory result, distinct from success and from failure. The
Resultpattern 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:EventsControllertranslates the flag into anX-Warningresponse header and still returns 200 with the DTO (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:240-248), so the body stays exactly theEventDTOthe 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 wideningEventDTOwith aHasTimeZoneWarningproperty 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
UpdateEventHandlerin itsBuildResultoverride (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 onlyresult.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
UpdateSessionHandlerreturns: the updatedSessionDTOplus a boolean saying whether the new session times fall outside the parent event's date range. - Depends on:
SessionDTOfromMMCA.ADC.Conference.Shared.Sessions(UpdateSessionCommand.cs:2,25). The second member is abool. - 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]:SessionsControllerreads the flag, appends anX-Warningresponse 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 ofSessionDTOis the load-bearing part:HasDateRangeWarningis 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
UpdateSessionHandlerin itsBuildResultoverride (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 readsresult.Value!.HasDateRangeWarning(:338) and then returnsresult.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),CategoryInvariantsfrom the Domain layer for the length bound (:4,34),Expression<Func<T, string>>(:2,31) andCultureInfo(:1,34). - Concept introduced: the length constant is read, never retyped.
MaximumLength(CategoryInvariants.CategoryItemNameMaxLength)(:34) resolves throughCategoryInvariants.CategoryItemNameMaxLength(MMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:24) toCategoryItemDTO.NameMaxLength, which is theconst int 500that 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 withstring.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-bodiedRuleFor(selector)chain with two links:NotEmpty()carrying the message "You must enter a Category Item Name" and the codeCategoryItem.Name.Required(:33), thenMaximumLength(...)with the interpolated message and the codeCategoryItem.Name.MaxLength(:34). Every link states its ownWithErrorCode, 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) andUpdateCategoryItemCommandValidator(MMCA.ADC.Conference.Application/Categories/UseCases/UpdateCategoryItem/UpdateCategoryItemCommandValidator.cs:11), each pairing it withCategoryItemSortRules<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
Categorytitle: non-empty, and no longer than the domain's declared maximum. - Depends on: FluentValidation's
AbstractValidator<T>(ConferenceCategoryValidationRules.cs:3,15),CategoryInvariantsfor the bound (:4,20),Expression<Func<T, string>>(:2,17) andCultureInfo(: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) chainsNotEmpty()with the message "You must enter a Category Title" and the codeCategory.Title.Required(:19), thenMaximumLength(CategoryInvariants.TitleMaxLength)with an invariant-culture interpolated message and the codeCategory.Title.MaxLength(:20). The bound resolves toConferenceCategoryDTO.TitleMaxLength, theconst 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.EnsureTitleIsValidre-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) andConferenceCategoryUpdateRequestValidator(MMCA.ADC.Conference.Application/Categories/UseCases/Update/ConferenceCategoryUpdateRequestValidator.cs:10), each binding it to its own request'sTitle.
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, theEventUpdateRequestbody, and the caller'sIf-Matchconcurrency token, marked as cache-invalidating. - Depends on:
ICommandWithRequest<out TRequest>andICacheInvalidatingfromMMCA.Common.Application.UseCases(UpdateEventCommand.cs:3,4,16),EventUpdateRequest(:15), and theEventaggregate type, used only for itsFullNamein the cache prefix (:1,18).EventIdentifierTypeis the module identifier alias. - Concept introduced: the concurrency token as a command parameter.
byte[] RowVersionis 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 theIf-Matchheader, 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 isCachePrefix => $"{typeof(Event).FullName}:"(:18), the key namespace the caching decorator evicts on success.UpdateEventResultsits 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 ambientSaveChangesAsyncboundary suffices, and the transactional decorator is reserved for commands that coordinate multiple aggregates (ADR-014; seeTransactionalCommandDecorator<TCommand, TResult>). Deriving the cache prefix fromtypeof(Event).FullNamerather 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
EventsControllerfrom the route id, the body and the decoded token (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:230-234) against the injectedICommandHandler<UpdateEventCommand, Result<UpdateEventResult>>(:49), and handled byUpdateEventHandler.
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), andEventFieldRules<T>fromMMCA.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 overIEventFieldsRequest(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), andEventCreateRequestValidatorincludes the same one, so the two entry paths cannot drift by an include. What remains in each file is only its delta. Here that isRuleFor(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;IsInEnumrejects a value outside the two declared members before the handler runs, with the stable codeEvent.QuestionModerationDefault.Invalid(:18; enum members atMMCA.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 overT : IEventFieldsRequest(EventValidationRules.cs:141), which is the constraintEventUpdateRequestsatisfies by implementing the interface. Fields with no rule at all:Description,SessionizeCode,VenueAddress,VenueMapUrlandWiFiInfo. - Why it's built this way: length bounds are not literals anywhere on this path. Each included rule set reads its constant from
EventInvariantsin 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.FindSystemTimeZoneByIdand treatsTimeZoneNotFoundExceptionas 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'sICommandWithRequest<out TRequest>implementation, and executed by theValidatingCommandDecorator<TCommand, TResult>beforeUpdateEventHandlerruns.
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), andSessionFieldRules<T>fromMMCA.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.SessionCreateRequestValidatorincludes the sameSessionFieldRules<T>and then one more rule set,SessionEventIdRules<T>(MMCA.ADC.Conference.Application/Sessions/UseCases/Create/SessionCreateRequestValidator.cs:11,16), because on createEventIdis 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 singleInclude(new SessionFieldRules<SessionUpdateRequest>())(:11-12). That one type includes seven rule sets overISessionFieldsRequest(MMCA.ADC.Conference.Application/Sessions/Validation/SessionValidationRules.cs:111-124):SessionTitleRules<T>,SessionDescriptionRules<T>,SessionStatusRules<T>,SessionLiveUrlRules<T>,SessionRecordingUrlRules<T>,SessionAccessibilityInfoRules<T>andSessionResourceLinksRules<T>. Fields with no rule at all:EventId,StartsAt,EndsAt, the four booleans andRoomId. - 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
SessionInvariantsin 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.
StartsAtandEndsAtcarry no validator rule; the ordering invariant is enforced inside the aggregate bySessionInvariants.EnsureEndsAtIsAfterStartsAt, called fromSession.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 inSessionRoomScheduling, invoked from the handler. - Where it's used: discovered by assembly scanning, reached through
UpdateSessionCommand'sICommandWithRequest<out TRequest>implementation, and executed by theValidatingCommandDecorator<TCommand, TResult>beforeUpdateSessionHandlerruns.
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, theSessionUpdateRequestpayload and the caller'sIf-Matchtoken, opted into cache eviction. - Depends on:
ICommandWithRequest<out TRequest>andICacheInvalidating(UpdateSessionCommand.cs:3,4,16), theSessionUpdateRequestit wraps (:15), and theSessiontype used only for itsFullNamein the cache prefix (:1,18). The file also declaresUpdateSessionResult(:24), which is why it importsSessionDTO(:2). - Concept introduced: none new; this is the id-plus-request-plus-token shape taught at
UpdateEventCommand, byte for byte including the doc comment onRowVersion(:10-14). What is worth noting here is what the two marker interfaces buy and what they do not.ICacheInvalidatingopts the command into theCachingCommandDecorator<TCommand, TResult>so a successful update evicts the session read cache under this prefix, andICommandWithRequest<out TRequest>is what routesSessionUpdateRequestValidatorinto the pipeline. Neither isITransactional, so this command runs without an explicit ambient transaction; the base handler's singleSaveChangesAsyncis 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 typeUpdateSessionResultshares the file (:24). - Why it's built this way: deriving the cache prefix from
typeof(Session).FullNamerather 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,SessionsControllerevicts the ASP.NET output cache by tag after a successful update (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:344, tagsconference:sessionsandconference). One is the handler's data cache, the other is the HTTP response cache. - Where it's used: constructed by
SessionsControllerfrom the route id, the body and the decoded token (MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:329-333) and handled byUpdateSessionHandler.
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), theEventaggregate (:3), theSessionaggregate used only for the existence probe (:4),IEntityReader<TEntity, TIdentifierType>(:5,50),UpdateEventResult(:22), andILogger<T>fromMicrosoft.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 withNotFoundwhen 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 aSkipSaveshort-circuit (:299-300), save (:302), then log and run the post-commit hook (:304-305). The payload flavor adds the answer-shaping step:HandleAsynccreates the context, runs the core, and callsBuildResulton 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 isMutationContext, 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 inBuildResult(context.GetOrDefault<bool>(...)at:86; the reader isMutationContext.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 classwith primary-constructor DI (:18-22);partialbecause the log message is source-generated (:88-89).EntityIdreturnscommand.Id(:28).RowVersionreturnscommand.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 askingExistsAsync(s => s.EventId == command.Id, ...)(:52-54). It stores the answer in the context (:57) and returnsentity.Update(...)with all thirteen fields in declaration order (:59-72).LogMutated(:76-77) emits "Event {EventId} updated".BuildResult(:80-86) maps the entity throughEventDTOMapperand 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 atSaveChangesAsyncregardless of which branch the BR-131 check took. The comparison happens beforeUpdate, becauseUpdateis what destroys the evidence. And every field goes into oneUpdatecall rather than property assignments, so the aggregate remains the only writer of its own state, re-runs its invariants throughEventInvariants(MMCA.ADC.Conference.Domain/Events/Event.cs:271-274) and raises exactly oneEventChangedper update (Event.cs:265), which the outbox picks up on the same save (ADR-003).[Rubric §12, Performance & Scalability]: the probe is conditional and usesExistsAsyncrather than materializing sessions, and it goes throughGetReadRepository(: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
EventsControllerasICommandHandler<UpdateEventCommand, Result<UpdateEventResult>>(MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:50) and invoked onPUT {id}(:231-233); the controller appends anX-Warningresponse 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 asUpdateEventHandler, its mutation enforces BR-140 (the owning event is immutable), loads the parent event for the BR-130 room checks, runs the domainUpdate, 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),ResultandError(:8), theSession(:5) andEvent(:4) aggregates,IEntityReader<TEntity, TIdentifierType>(:6,55),UpdateSessionResult(:23) andILogger<T>(:1,22). - Concept introduced: guard ordering inside a mutation. The four checks in
MutateAsyncare not interchangeable, and they run cheapest-and-most-certain first. BR-140 is a pure in-memory comparison ofcommand.Request.EventIdagainst the loaded entity'sEventId, so it runs first and costs nothing (:45-52); a mismatch returnsError.UnprocessableEntitywith the codeSession.EventId.Immutable(:47-51) and no query is ever issued. Only then does the handler pay for the parent event, loaded with itsRoomscollection and explicitly untracked because it is read, never written (:55-61); a missing parent is aNotFoundfailure sourced to this handler (:62-63). The room rules follow, because they need that event. The domainUpdateis 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 insideSession, 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 classwith primary-constructor DI (:19-23), keyed context constant at:26.EntityIdreturnscommand.Id(:29);RowVersionreturnscommand.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 withincludes: [nameof(Event.Rooms)]andasTracking: false(:55-61), then delegates the room work toSessionRoomScheduling.ValidateRoomAssignmentAsync(:66-76), passingexcludeSessionId: command.Id(:72) so the session does not collide with itself. It callsentity.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 helperIsOutsideEventDateRange(:98-100, helper at:122-131), which comparesDateOnly.FromDateTime(startsAt)against the event'sStartDateand the end againstEndDate.LogMutated(:106-107) emits "Session {SessionId} updated";BuildResult(:110-116) maps the entity throughSessionDTOMapperand 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 nullRoomIdshort-circuits to success (:56-57), a room that is not in the parent event'sRoomsfails withSession.RoomId.CrossEvent(:59-67), an unscheduled session skips the overlap probe (:69-70), and the overlap itself is anExistsAsyncover a SQL-translatable half-open-interval predicate (:74-76; predicate built at:93-106). Passing every field into oneUpdatecall keeps the aggregate the only writer of its own state and yields exactly oneSessionChangeddomain 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
SessionsControllerasICommandHandler<UpdateSessionCommand, Result<UpdateSessionResult>>(MMCA.ADC.Conference.API/Controllers/Sessions/SessionsController.cs:48) and invoked onPUT {id}(:330-332); the controller appends theX-Warningheader 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. Eleveninit-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:
ISpeakerFieldsRequestfromMMCA.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.LinkedUserIdis 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.Updateassigns ten fields plusIsTopSpeakerand deliberately never touches the link, because onlyLinkUserandUnlinkUsercarry the BR-208 uniqueness check and raise the events that keep Identity'sUser.LinkedSpeakerIdin 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 implementIConcurrencyAwareand carries noRowVersion. The token arrives in theIf-Matchheader instead, decoded by the filter and read out withSupportsIfMatchAttribute.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 timeUpdateSpeakerCommandis 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 keepsCallerIsOrganizeroff this record and on the command (SpeakerUpdateApplier).- No linked-user field.
Walkthrough:
public record class SpeakerUpdateRequest : ISpeakerFieldsRequest(:8). Two members arerequiredand so cannot be omitted by a caller:FirstName(:11) andLastName(:14). The optional strings areEmail(:17),Bio(:20),TagLine(:23),ProfilePicture(:26),TwitterHandle(:32),LinkedInUrl(:35),GitHubUrl(:38), andWebsiteUrl(:41).IsTopSpeaker(:29) is the one non-string member, a plainbooldefaulting tofalse, and it is the field the applier ignores for a non-organizer caller. Every member isinit-only, so the bound request is immutable for the rest of the request lifetime.Why it's built this way: implementing
ISpeakerFieldsRequestis what lets the create and the update request share one rule list.SpeakerFieldRules<T>is generic overT : ISpeakerFieldsRequestand 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 oneInclude, not an edit in two validators that can drift apart.Where it's used: bound from the body by
SpeakersControlleronPUT {id}(MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:340), validated bySpeakerUpdateRequestValidator, wrapped intoUpdateSpeakerCommandwith the role flag and the header token (:351), and consumed field by field bySpeakerUpdateApplier.
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
Sponsoror exhibitor: the display identity, the tier, the marketing links, the display order, and the expo-booth pair. - Depends on:
ISponsorFieldsRequestfromMMCA.ADC.Conference.Application.Sponsors.Validationand theSponsorTierenum fromMMCA.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 mistypedEventIdcannot 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).Nameis the singlerequiredmember (:14).Tier(:17) is theSponsorTierenum,Sort(:35) the integer display order within the tier, andIsExhibitor(:38) the boolean that pairs with the optionalBoothNumber(:41). The remaining optional strings areLogoUrl(:20),Description(:23),WebsiteUrl(:26),LinkedInUrl(:29), andTwitterHandle(:32). All members areinit-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). CompareUpdateSpeakerCommand, 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:
Tiercarries noIsInEnumrule inSponsorUpdateRequestValidator;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
SponsorsControlleronPUT {id}(MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:189), validated bySponsorUpdateRequestValidator, and applied bySponsorUpdateApplierunder the framework's generic update handler, registered with oneAddEntityCrudcall (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
SpeakerChangeddomain event, ignores all but theDeletedones, logs the deletion, and publishes theSpeakerUnlinkedFromUserintegration event so the Identity module can clear the other half of the link.Depends on:
IDomainEventHandler<in TDomainEvent>closed overSpeakerChanged(SpeakerDeletedHandler.cs:3,5,22),IEventBusresolved per invocation (:5,41),SpeakerUnlinkedFromUser(:4,43),DomainEntityState(:6,29), and two externals:IServiceScopeFactory(Microsoft.Extensions.DependencyInjection,:1,21) andILogger<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.LinkedUserIdin Conference andUser.LinkedSpeakerIdin 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 insideSpeaker.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'sSpeakerUnlinkedFromUserHandlerapplies 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 noSpeakerDeletedtype. - 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 scopedIEventBus, so the handler injectsIServiceScopeFactoryand opens its ownawait usingscope 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.- 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
Walkthrough: the class is
sealed partialwith a primary constructor taking the scope factory and the logger (:20-22);partialbecause[LoggerMessage]generates the log method body into the other half.HandleAsync(:25) null-guards the event (:27), applies the state gate (:29-30), and emitsLogSpeakerDeletedwith 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, resolvesIEventBusfrom it, and awaitsPublishAsync(new SpeakerUnlinkedFromUser(previousLinkedUserId, speakerId), ct)withConfigureAwait(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.Sharedrather 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 byDomainEventDispatcheras 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 existingCategory. Four positional members and one computed cache prefix. - Depends on:
ICacheInvalidatingfromMMCA.Common.Application.UseCases(AddCategoryItemCommand.cs:2,18) and theCategorydomain type, used only for itsFullNamein the prefix (:1,21).ConferenceCategoryIdentifierTypeandCategoryItemIdentifierTypeare 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 ownAddCategoryItemRequestis 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 andAddCategoryItemCommandValidatortargets the command type directly. The second point of interest isCategoryItemId(: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).CategoryIdis the parent to load,NameandSortare the child's two editable fields.CachePrefixis an expression-bodied property returning$"{typeof(Category).FullName}:"(:21), the key namespace theCachingCommandDecorator<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).FullNamerather than a literal keeps the writer and the cached reader agreed on one key namespace that a rename cannot desynchronize. The command implements noITransactionalmarker: the handler writes one aggregate and saves once, so the ambient save boundary suffices (ADR-014). - Where it's used: constructed by
CategoryItemsControlleronPOSTagainst the injectedICommandHandler<AddCategoryItemCommand, Result<CategoryItemDTO>>(MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:65,128-140), and handled byAddCategoryItemHandler. That action carries[Idempotent](:129), so a retried request with the sameIdempotency-Keyreplays 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
CategoryItementity into aCategoryItemDTO, 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 theRiok.Mapperly.Abstractions[Mapper]attribute (:4,11). - Concept introduced: the source-generated DTO mapper. The body of
MapToDTOis not in this file.[Mapper]on apartialclass 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:CategoryItemDTOdeclaresId,Name,SortandCategoryId(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) thenpublic sealed partial class CategoryItemDTOMapper : IEntityDTOMapper<CategoryItem, CategoryItemDTO, CategoryItemIdentifierType>(:12-13). One generated member,MapToDTO(:16).MapToDTOsis written out by hand as a null-guard plus a collection expression overSelect(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 intoUpdateCategoryItemHandler; nested as a child mapper byConferenceCategoryDTOMapper(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 asEntityQueryService<TEntity, TEntityDTO, TIdentifierType>resolve it asIEntityDTOMapper<...>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 fromMMCA.ADC.Conference.Application.Categories.Validation(:2,11-12):CategoryItemNameRules<T>andCategoryItemSortRules<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>.AddCategoryItemCommandcarries no request, so the bridge has nothing to bridge and the validator names the command type directly (:7). Nothing else changes: theValidatingCommandDecorator<TCommand, TResult>resolvesIValidator<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 twoIncludecalls with property selectors:CategoryItemNameRules<AddCategoryItemCommand>(p => p.Name)(:11) contributesNotEmptyplusMaximumLength(CategoryInvariants.CategoryItemNameMaxLength)with the stable codesCategoryItem.Name.RequiredandCategoryItem.Name.MaxLength(MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:31-34), andCategoryItemSortRules<AddCategoryItemCommand>(p => p.Sort)(:12) is a thin subclass of the sharedNonNegativeIntRules<T>carrying the codeCategoryItem.Sort.Negative(ConferenceCategoryValidationRules.cs:41-46).CategoryIdandCategoryItemIdcarry no rule: a missing parent is aNotFoundthe 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 theValidatingCommandDecorator<TCommand, TResult>ahead ofAddCategoryItemHandler.
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
Categoryaggregate, producing aConferenceCategoryDTOwith its child items mapped throughCategoryItemDTOMapper. - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>closed over the category types (ConferenceCategoryDTOMapper.cs:1-3,15),CategoryItemDTOMapperinjected 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.CategoryItemsis 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 emittedMapToDTOprojects eachCategoryItemthroughCategoryItemDTOMapperrather 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 toCategoryItemDTOfrom 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), thenpublic 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.MapToDTOis the generatedpartial(:21);MapToDTOsis the hand-written null-guard plus projection (:24-28). Note the naming asymmetry the mapper straddles: the domain type isCategory, the DTO isConferenceCategoryDTO, and the identifier alias isConferenceCategoryIdentifierType, so this file is the place where the two vocabularies meet. The target DTO also carries aRowVersion(MMCA.ADC.Conference.Shared/Categories/ConferenceCategoryDTO.cs:26), which is what lets a client echo the token back as anIf-Matchprecondition on the next update (ADR-035). - Caveats / not-in-source: whether the
CategoryItemscollection 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 emptyCategoryItemslist. - Where it's used: injected into
CreateConferenceCategoryHandler(MMCA.ADC.Conference.Application/Categories/UseCases/Create/CreateConferenceCategoryHandler.cs:18), and resolved asIEntityDTOMapper<Category, ConferenceCategoryDTO, ConferenceCategoryIdentifierType>by the framework's generic write and read paths registered throughAddEntityCrud<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>fromMMCA.Common.Application.UseCasesas its base record (UpdateSpeakerCommand.cs:2,25),SpeakerUpdateRequest(:22), and theSpeakertype, 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 flagIsTopSpeaker. 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 inheritedId,RequestandRowVersion; the inheritedCachePrefix, defaulting totypeof(Speaker).FullName + ":"(UpdateEntityCommand.cs:65); and theICommandWithRequest<TRequest>implementation that keeps theIValidator<SpeakerUpdateRequest>bridge running so noUpdateSpeakerCommandValidatorhas 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; onlyCallerIsOrganizeris new.RowVersionis a non-nullablebyte[]because the endpoint is conditional:SupportsIfMatchAttributeanswers 428 for a missingIf-Matchand 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
AddEntityUpdaterather than the plainAddEntityCrudused 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 overUpdateSpeakerCommandwithTryAddand registers the command-to-request validator bridge (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:444-459).TryAddis the load-bearing detail: the module's ownUpdateSpeakerHandlerwas 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
SpeakersControllerafter 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 injectedICommandHandler<UpdateSpeakerCommand, Result<SpeakerDTO>>(:49); consumed bySpeakerUpdateApplierand handled byUpdateSpeakerHandler.
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),IUnitOfWorkforwarded to the base (:5,16),CategoryItemDTOMapper(:2,17),Result(:7,29), theCategoryandCategoryItemdomain types (:3),CategoryItemDTO(:4), andILogger<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
NotFoundtagged with the handler name and the parent type when it is gone, callApply, 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 notry, no null check and no save. Two hooks are worth calling out because their defaults encode a policy.AsTrackingdefaults totrue, since a no-tracking load would turn the add into a silent no-op (ChildEntityHandlerBase.cs:50). AndIncludesis 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 raisesCategoryItemChanged(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);ParentIdreturnscommand.CategoryId(:26);Applyis one line into the aggregate,parent.AddCategoryItem(command.CategoryItemId, command.Name, command.Sort)(:29-30);MapChildis one line into the mapper (:33);LogAddedforwards to the source-generated partial with the item name and the category id (:36-40). - Caveats / not-in-source: the empty
Includesand the aggregate's uniqueness check are in tension, and it is worth knowing which guard actually holds.Category.AddCategoryItemopens with BR-138, a case-insensitive uniqueness check against the in-memory_categoryItemscollection (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 noAutoIncludeconfigured 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>>byCategoryItemsController(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
UpdateSpeakerCommandbecomes a call toSpeaker.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), theSpeakeraggregate (:1),UpdateSpeakerCommandandSpeakerUpdateRequest(:14),ResultandMutationContext(: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 (seeSponsorUpdateApplier). 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:LinkedUserIdis not in this path at all, because the governed/linkand/unlinkendpoints 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).ApplyAsynctakes the tracked entity, the command, theMutationContextand a token (:17-22), null-guards entity and command (:23-24), computes the gated flag (:32), and returnsTask.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. TheMutationContextparameter is accepted and unused: this update neither needs to carry a derived value out to the post-save hooks nor to callSkipSave. - Why it's built this way: returning the aggregate's own
Resultunchanged is the contract.Speaker.Updatefirst parses the optional email into anEmailvalue 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 raisesSpeakerChanged(: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 theIEntityUpdateCommandApplier<...>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 oneIncludeof the shared speaker field rules. - Depends on:
AbstractValidator<T>(FluentValidation,SpeakerUpdateRequestValidator.cs:1,7),SpeakerUpdateRequest, andSpeakerFieldRules<T>fromMMCA.ADC.Conference.Application.Speakers.Validation(:2,10). - Concept introduced: none new; this is
Includecomposition (taught in group 06) in its most compact form, but note what it includes.SpeakerFieldRules<T>is constrainedwhere T : ISpeakerFieldsRequestand selects its properties through the interface, so it takes no selector arguments at all (MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:113-125). CompareAddCategoryItemCommandValidator, 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 includesnew SpeakerFieldRules<SpeakerUpdateRequest>(). That rule set folds in six rule sets in turn (SpeakerValidationRules.cs:119-124): first and last name throughRequiredStringRules<T>bounded bySpeakerInvariants(:12-28), the optional email throughEmailRules<T>under a non-blankWhenguard (:38-48), and LinkedIn, GitHub and website URLs throughAbsoluteUrlRules<T>under the same guard (:57-105). TheWhenwrapper is what makes clearing an optional field always legal. Fields with no rule here:Bio,TagLine,ProfilePicture,TwitterHandleandIsTopSpeaker, 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 inheritedICommandWithRequest<out TRequest>implementation, which the framework bridges with aCommandRequestValidator<TCommand, TRequest>registered byAddEntityUpdate(MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:456). TheValidatingCommandDecorator<TCommand, TResult>runs it beforeUpdateSpeakerHandler.
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
SponsorUpdateRequestbecomes a call toSponsor.Update. - Depends on:
IEntityUpdateApplier<TEntity, TUpdateRequest, TIdentifierType>(SponsorUpdateApplier.cs:2,13), theSponsoraggregate (:1),SponsorUpdateRequest(:13), andResult(:3,16). - Concept introduced: none new; it is the request-only twin of
SpeakerUpdateApplierand 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).ApplyAsyncnull-guards the entity and the request (:18-19) and returnsTask.FromResult(entity.Update(...))with all ten request members in the aggregate's parameter order (:21-31).Sponsor.Updatecombines three invariant checks (name, logo URL, booth number) and returns early on failure, then assigns the ten fields and raisesSponsorChangedwithDomainEntityState.Updated(MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:165-183). NoMutationContextparameter 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 genericUpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>, which thePUTaction reaches throughUpdateEntityCommand<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: oneIncludeof the shared sponsor field rules. - Depends on:
AbstractValidator<T>(FluentValidation,SponsorUpdateRequestValidator.cs:1,7),SponsorUpdateRequest, andSponsorFieldRules<T>fromMMCA.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, samewhere T : IXFieldsRequestrule 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) includesnew 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 areTierandIsExhibitor, which is why the tier caveat noted atSponsorUpdateRequestis 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 theCommandRequestValidator<TCommand, TRequest>thatAddEntityCrudregisters, and executed by theValidatingCommandDecorator<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 aNotFoundfailure 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), theSpeakeraggregate (:3),SpeakerDTO(:4), andILogger<T>with[LoggerMessage](:1,21,34). - Concept introduced: subclassing a generic handler for vocabulary alone. There is no
HandleAsyncin 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), returnError.NotFoundtagged withHandlerNameand the entity type when it is gone (:281-282), stamp the caller's token withrepository.SetOriginalRowVersion(entity, rowVersion)when one is present (:290-291), run the mutation (:293), short-circuit on refusal (:294-295), honour aSkipSave(: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 theUPDATEpredicate 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 aNotFoundfailure reads as it always has.LogMutatedforwards 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 ispartialfor that generator, andsealedlike every other handler here. - Why it's built this way: the module could have skipped this file entirely and let
AddEntityUpdateregister the framework handler closed overUpdateSpeakerCommand. 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), thenAddEntityUpdateruns withTryAddand therefore does not displace it, while still completing the validator bridge (:150, explained in the block comment at:144-149; the framework side isMMCA.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>>bySpeakersController(MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:50,351), behind the standard decorator pipeline; after a success the controller evicts theconference:speakersandconferenceoutput-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 separateCreateConferenceCategoryCommand. - Depends on:
ICreateRequestandICacheInvalidatingfromMMCA.Common.Application.Interfaces/.UseCases(ConferenceCategoryCreateRequest.cs:2-3,10), theCategorydomain type used only to build the cache prefix (ConferenceCategoryCreateRequest.cs:1,13), and theConferenceCategoryIdentifierTypealias (= 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 markerICreateRequest(ConferenceCategoryCreateRequest.cs:10) is what lets the generic create machinery accept this record straight off the wire, hand it to anIEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, and dispatch it asICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>>. Note that it is declaredpublic record classrather thansealed 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 isrequired(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 readsCachePrefixoff 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-nullableConferenceCategoryIdentifierType, which matters downstream:Category.Createdeclares 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 to0and the factory's throw branch (Category.cs:69) is unreachable from the HTTP path (unreachable for this aggregate in any case, sinceCategorycarries[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).Titleisrequired(ConferenceCategoryCreateRequest.cs:19),Sortis a plainintdisplay order (ConferenceCategoryCreateRequest.cs:22), andTypeis the optional discriminator whose documented examples are "session" and "speaker" (ConferenceCategoryCreateRequest.cs:25). Every member isinit-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
ConferenceCategoriesControllercreate 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 asICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>>(ConferenceCategoriesController.cs:37); validated byConferenceCategoryCreateRequestValidator, converted byConferenceCategoryCreateRequestMapper, handled byCreateConferenceCategoryHandler. It is also one half of theAddEntityCrud<Category, ConferenceCategoryDTO, ConferenceCategoryIdentifierType, ConferenceCategoryCreateRequest, ConferenceCategoryUpdateRequest>()registration (MMCA.ADC.Conference.Application/DependencyInjection.cs:143); its edit-side counterpart isConferenceCategoryUpdateRequest.
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), andQuestionInvariantsfor 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
Tand taking anExpression<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'sQuestionTextproperty", and a consuming validator pulls the rules in withInclude(...), which copies every rule from anotherAbstractValidator<T>over the sameT. 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 codeQuestion.QuestionText.Required(QuestionValidationRules.cs:17), and.MaximumLength(QuestionInvariants.QuestionTextMaxLength)attaches "Question Text cannot be longer than 1000 characters" with codeQuestion.QuestionText.MaxLength(QuestionValidationRules.cs:18). The limit is1000, and it is worth following where it comes from:QuestionInvariants.QuestionTextMaxLengthis apublic const intthat forwards toQuestionDTO.QuestionTextMaxLength(MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:16), and the literal1000is 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 onQuestion.QuestionText.MaxLength. Second, this class writes its interpolated message with a plain$"..."(QuestionValidationRules.cs:18) where the category rules usestring.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
QuestionInvariantsand not a shared constants bag. Note the small difference between the two invariants classes: question limits areconst(QuestionInvariants.cs:16) while category limits arepublic 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) andQuestionUpdateRequestValidator(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
CategoryItemfrom its owningCategory. It names the category and the item, and nothing else. - Depends on:
ICacheInvalidating(RemoveCategoryItemCommand.cs:2,14), theCategorytype for the cache prefix (RemoveCategoryItemCommand.cs:1,17), and theConferenceCategoryIdentifierType/CategoryItemIdentifierTypealiases (both= int,MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:6-7). - Concept: the remove-child command shape, the mirror of
AddCategoryItemCommandand 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 onCategory, 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 recordwith two positional parameters,CategoryIdandCategoryItemId(RemoveCategoryItemCommand.cs:12-14), plus the single computedCachePrefix => $"{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 noRowVersionmember: unlike the conditional category update, a category-item removal states no optimistic-concurrency precondition, which is exactly the case the framework'sRowVersion(command)hook returnsnullfor (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:91). Note whatCategoryIdis 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 as0, andRemoveCategoryItemHandleris 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
RemoveChildOrNotFoundplus aCategoryItemChangeddomain event,MMCA.ADC.Conference.Domain/Categories/Category.cs:188-194). - Where it's used: constructed by the
CategoryItemsControllerasnew RemoveCategoryItemCommand(categoryId, id)(MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:186, handler injected atCategoryItemsController.cs:67) and handled byRemoveCategoryItemHandler. Its add and update counterparts areAddCategoryItemCommandandUpdateCategoryItemCommand.
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
CategoryIteminside its owningCategory: the two identifiers that locate it plus the two fields that may change. - Depends on:
ICacheInvalidating(UpdateCategoryItemCommand.cs:2,18), theCategorytype for the cache prefix (UpdateCategoryItemCommand.cs:1,21), and theConferenceCategoryIdentifierType/CategoryItemIdentifierTypealiases. - 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:
Nameis a non-nullablestringandSorta non-nullableint(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 resultingCategoryItemChangeddomain 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), withCachePrefix => $"{typeof(Category).FullName}:"(UpdateCategoryItemCommand.cs:21). UnlikeRemoveCategoryItemCommandthis command is always fully populated: the controller reads the item id from the route and the owning category id from arequiredmember 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 atCategoryItemsController.cs:66), validated byUpdateCategoryItemCommandValidator, and handled byUpdateCategoryItemHandler.
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
ConferenceCategoryCreateRequestinto aCategorydomain entity, by calling the aggregate'sCreatefactory and returning whateverResult<T>it produces. - Depends on:
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>fromMMCA.Common.Application.Interfaces(ConferenceCategoryCreateRequestMapper.cs:2,11-12), theCategoryaggregate and itsConferenceCategoryIdentifierTypealias (ConferenceCategoryCreateRequestMapper.cs:1), andResult<T>fromMMCA.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 anIEntityRequestMapper<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 plainsealed classwith 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 returnsTask<Result<Category>>rather than aCategory, 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 bynew. See ADR-001 for the no-reflection mapping policy. - Walkthrough: one method.
CreateEntityAsync(ConferenceCategoryCreateRequest request, CancellationToken)(ConferenceCategoryCreateRequestMapper.cs:15) null-guards withArgumentNullException.ThrowIfNull(request)(ConferenceCategoryCreateRequestMapper.cs:17), then returnsTask.FromResult(Category.Create(request.Id, request.Title, request.Sort, request.Type))(ConferenceCategoryCreateRequestMapper.cs:19-23). The method is synchronous in substance:Task.FromResultsatisfies 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 byCategoryInvariants.EnsureTitleIsValidthroughResult.Combine(Category.cs:61), the id is resolved against the[IdValueGenerated]check (Category.cs:65,69), and aCategoryChangeddomain event withDomainEntityState.Addedis queued on the new aggregate (Category.cs:72) for the outbox to pick up at save time. Because the request'sIdis non-nullable (ConferenceCategoryCreateRequest.cs:16), what actually reaches the factory is0when the client omits it. - Why it's built this way: pushing construction into
Category.Createmeans the invariant check and the domain event run for every caller, not just HTTP ones (the Sessionize importer calls the same factory atMMCA.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
CreateConferenceCategoryHandlerasIEntityRequestMapper<Category, ConferenceCategoryCreateRequest, ConferenceCategoryIdentifierType>(MMCA.ADC.Conference.Application/Categories/UseCases/Create/CreateConferenceCategoryHandler.cs:17), registered by the module's convention scan rather than an explicitAddScopedline (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
ConferenceCategoryCreateRequestbeforeCreateConferenceCategoryHandlersees it. It contains no rules of its own: it is a singleIncludecall. - Depends on:
FluentValidation.AbstractValidator<T>(NuGet,ConferenceCategoryCreateRequestValidator.cs:1,7) andConferenceCategoryTitleRules<T>fromMMCA.ADC.Conference.Application.Categories.Validation(ConferenceCategoryCreateRequestValidator.cs:2,10). - Concept: rule composition by
Includewith a property selector, the mechanism taught byQuestionTextRules<T>.Include(new ConferenceCategoryTitleRules<ConferenceCategoryCreateRequest>(p => p.Title))(ConferenceCategoryCreateRequestValidator.cs:10) copies the shared title rules onto this request'sTitleproperty, and the update-side validator includes the same rule object againstConferenceCategoryUpdateRequest, so create and update cannot disagree about what a valid title is. Equally instructive is what is not validated:SortandTypehave no rules at all here, even though a sibling rule object for a sort value exists (CategoryItemSortRules<T>, applied only to category items byAddCategoryItemCommandValidatorandUpdateCategoryItemCommandValidator). A negativeSorton 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 atMMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:17-20:NotEmptywith codeCategory.Title.Required(ConferenceCategoryValidationRules.cs:19), thenMaximumLength(CategoryInvariants.TitleMaxLength)with codeCategory.Title.MaxLength(ConferenceCategoryValidationRules.cs:20), where the limit is255, 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 aspublic static readonly intrather thanconst, 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.Createkeeps its ownEnsureTitleIsValidcheck (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 byConferenceCategoryCreateRequestValidatorTests(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
EventQuestionAnswerdomain entity into anEventQuestionAnswerDTO. The single-entity method has no body in this file: Mapperly generates it at compile time. - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>fromMMCA.Common.Application.Interfaces(MMCA.ADC.Conference.Application/Events/DTOs/EventQuestionAnswerDTOMapper.cs:3,13),Riok.Mapperly.Abstractions(NuGet,EventQuestionAnswerDTOMapper.cs:4,11), theEventQuestionAnswerentity (EventQuestionAnswerDTOMapper.cs:1), theEventQuestionAnswerDTOcontract from the Shared project (EventQuestionAnswerDTOMapper.cs:2), and theEventQuestionAnswerIdentifierTypealias (= int,MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:9). - Concept introduced (for this unit), source-generated DTO mapping.
[Mapper]on apartialclass (EventQuestionAnswerDTOMapper.cs:11-12) tells the Mapperly generator to fill in the body of everypartialmethod it finds, hereMapToDTO(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 withArgumentNullException.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 intermediateList<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:93and:116), consumed as a child mapper byEventDTOMapperthrough[UseMapper](MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:27-28), and resolved asIEntityDTOMapper<EventQuestionAnswer, EventQuestionAnswerDTO, EventQuestionAnswerIdentifierType>by the genericEntityQueryService<TEntity, TEntityDTO, TIdentifierType>registered for the entity (MMCA.ADC.Conference.Application/DependencyInjection.cs:107, constructor parameter atMMCA.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
EventSpeakerassociation entity toEventSpeakerDTO. - 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 theEventSpeakerIdentifierTypealias (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:10). - Concept: nothing new; the
[Mapper]-plus-partialshape taught byEventQuestionAnswerDTOMapper. 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-writtenMapToDTOswith its null guard and spread projection (EventSpeakerDTOMapper.cs:19-23). - Where it's used: injected concretely into
AddEventSpeakerHandler, which uses it for theMapChildhook (MMCA.ADC.Conference.Application/Events/UseCases/AddEventSpeaker/AddEventSpeakerHandler.cs:17,34), used as a child mapper byEventDTOMapper(MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:24-25), and resolved by the query service registered atMMCA.ADC.Conference.Application/DependencyInjection.cs:104. Tested byEventSpeakerDTOMapperTests.
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
ifinside every read endpoint (MMCA.ADC.Conference.Application/Events/Specifications/PublishedEventSpecification.cs:7-9). - Depends on:
Specification<TEntity, TIdentifierType>fromMMCA.Common.Domain.Specifications(PublishedEventSpecification.cs:3,11), theEventaggregate and itsEventIdentifierTypealias (PublishedEventSpecification.cs:2,11), andSystem.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
Criteriaexpression 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 answers404rather than403, 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:
EventsControllersupplies it from exactly one override,protected override Specification<Event, EventIdentifierType>? GetExportSpecification(), which returnsnull(no filter) whencurrentUserService.IsPrivilegedConferenceReader()and a new instance otherwise (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:75-76). That is the framework's synchronous scoping hook:GetReadSpecificationAsyncreturnsValueTask.FromResult(GetExportSpecification())by default (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EntityControllerBase.cs:597-599), and every read action (bothGetAllAsyncoverloads,GetAllForLookupAsync,GetByIdAsyncandExportAsync) 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 aForbid()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
Roomchild entity to aRoomDTO. - 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 theRoomIdentifierTypealias (MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:12). - Concept: nothing new; the
[Mapper]-plus-partialshape taught byEventQuestionAnswerDTOMapper. - Walkthrough:
public partial RoomDTO MapToDTO(Room entity)(RoomDTOMapper.cs:16) generated by Mapperly, plus the hand-writtenMapToDTOswith 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 byEventDTOMapper(MMCA.ADC.Conference.Application/Events/DTOs/EventDTOMapper.cs:21-22), and resolved by the query service registered atMMCA.ADC.Conference.Application/DependencyInjection.cs:98. Tested byRoomDTOMapperTests.
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 twoIncludecalls. - Depends on:
FluentValidation.AbstractValidator<T>(NuGet,UpdateCategoryItemCommandValidator.cs:1,7) and the two shared rule objectsCategoryItemNameRules<T>andCategoryItemSortRules<T>fromMMCA.ADC.Conference.Application.Categories.Validation(UpdateCategoryItemCommandValidator.cs:2,11-12). - Concept: validating a command rather than a request.
ConferenceCategoryCreateRequestValidatortargets 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 justTto 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 anExpression<Func<T, string>>(MMCA.ADC.Conference.Application/Categories/Validation/ConferenceCategoryValidationRules.cs:28,31), whileCategoryItemSortRules<T>is a two-line subclass of the framework'sNonNegativeIntRules<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) andCategoryItem.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) andInclude(new CategoryItemSortRules<UpdateCategoryItemCommand>(p => p.Sort))(UpdateCategoryItemCommandValidator.cs:12). The name rule enforcesNotEmptyplusMaximumLength(CategoryInvariants.CategoryItemNameMaxLength), which is500(MMCA.ADC.Conference.Shared/Categories/CategoryItemDTO.cs:16, forwarded atMMCA.ADC.Conference.Domain/Categories/CategoryInvariants.cs:24); the sort rule resolves to the framework'sGreaterThanOrEqualTo(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 byUpdateCategoryItemCommandValidatorTests, which shares a file with its add-side twin (MMCA.ADC.Conference.Application.Tests/Categories/Validation/CategoryCommandValidatorTests.cs:54). Its add-side sibling isAddCategoryItemCommandValidator, which is the same twoIncludecalls 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>fromMMCA.Common.Application.UseCases(CreateConferenceCategoryHandler.cs:7,20-21),IUnitOfWork(CreateConferenceCategoryHandler.cs:6,16),IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>satisfied byConferenceCategoryCreateRequestMapper(CreateConferenceCategoryHandler.cs:5,17),ConferenceCategoryDTOMapper(CreateConferenceCategoryHandler.cs:2,18),ILogger<T>with a source-generated[LoggerMessage]partial (CreateConferenceCategoryHandler.cs:1,19,26-27), theCategoryaggregate (CreateConferenceCategoryHandler.cs:3), andConferenceCategoryDTOfrom 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'sIEntityDTOMapper<...>parameter. Notice equally what is absent: no validator call (the validation decorator already ranConferenceCategoryCreateRequestValidator), no cache eviction (the caching decorator readsCachePrefixoff the request), and notry/catch(failures arrive asResultvalues). 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 theCategoryChangedevent raised inside the factory (MMCA.ADC.Conference.Domain/Categories/Category.cs:72) is captured by the base's singleSaveChangesAsync.[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 partialLogConferenceCategoryCreated(logger, entity.Id, entity.Title)(CreateConferenceCategoryHandler.cs:24, message template atCreateConferenceCategoryHandler.cs:26-27); the hook is invoked after the save (CreateEntityHandlerBase.cs:99), so the logged id is the store-generated key rather than the0that arrived on the request. The inherited sequence is worth reading once:HandleAsyncnull-guards the command and delegates toCreateCoreAsync(CreateEntityHandlerBase.cs:56-63), which runs the pass-throughPrepareAsynchook (CreateEntityHandlerBase.cs:84,114-118), awaitsrequestMapper.CreateEntityAsyncand short-circuits a factory failure into the right generic shape withResult.Failure<TEntityDTO>(result.Errors)(CreateEntityHandlerBase.cs:90-92), resolvesattemptUnitOfWork.GetRepository<TEntity, TIdentifierType>()(CreateEntityHandlerBase.cs:95), callsPersistAsync, whose default isAddAsyncfollowed by exactly oneSaveChangesAsync(CreateEntityHandlerBase.cs:129-140), thenLogCreated, then the no-opOnCreatedAsyncpost-commit hook, and finally returnsResult.Success(dtoMapper.MapToDTO(entity))(CreateEntityHandlerBase.cs:97-102). This handler overrides none ofPrepareAsync,PersistAsyncorOnCreatedAsync: a category needs no app-assigned key, no bespoke persist step, and no post-commit publish. - Why it's built this way: the single
SaveChangesAsyncis 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 overridingPersistAsync. Note also whyCreateCoreAsynctakes 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 ambientDbContextstill tracks the failed insert (CreateEntityHandlerBase.cs:29-36,69-73). Returning aConferenceCategoryDTOrather than the entity keeps the domain type from crossing the API boundary, per ADR-001. - Where it's used: resolved by the
ConferenceCategoriesControllerasICommandHandler<ConferenceCategoryCreateRequest, Result<ConferenceCategoryDTO>>(MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:38) and reached throughAggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>(ConferenceCategoriesController.cs:42), whose create action the ADC controller overrides only to evict theconference:categoriesoutput-cache tag after the call (ConferenceCategoriesController.cs:95-103). The category's edit path is not a sibling handler: it is the framework's genericUpdateEntityHandler<TEntity, TEntityDTO, TIdentifierType, TUpdateRequest>plus aConferenceCategoryUpdateApplier, wired by the sameAddEntityCrud<...>()call (MMCA.ADC.Conference.Application/DependencyInjection.cs:143). Covered byCreateConferenceCategoryHandlerTests(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
Eventaggregate. 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, andEventQuestionAnswerDTOMapper(EventDTOMapper.cs:16-18), theEventaggregate (EventDTOMapper.cs:1),EventDTOfrom the Shared project (EventDTOMapper.cs:2), theEmailvalue object from Common's shared contact value objects (EventDTOMapper.cs:4,55), andSystem.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 aRoom, anEventSpeaker, or anEventQuestionAnswer, 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 isLastSessionizeRefreshBy, aUserIdentifierType?on the entity (MMCA.ADC.Conference.Domain/Events/Event.cs:86) but astring?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 anEmail?on the entity as astring?on the DTO. That is the case forOrganizerContactEmail, anEmail?value object on the aggregate (MMCA.ADC.Conference.Domain/Events/Event.cs:59) and a plainstring?on the DTO (MMCA.ADC.Conference.Shared/Events/EventDTO.cs:82), per ADR-068. Note the difference from theLastSessionizeRefreshBycase: a one-line unwrap the generator can call for itself stays declarative, while a conversion needing aCultureInfodecision 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 onewithexpression.[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 usesCultureInfo.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 publicMapToDTO(Event entity)(EventDTOMapper.cs:31-42) is hand-written: it null-guards (EventDTOMapper.cs:33), calls the private generatedMapToDTOGenerated(entity)(EventDTOMapper.cs:34), and then returns awithexpression that sets the one ignored member,LastSessionizeRefreshBy = entity.LastSessionizeRefreshBy?.ToString(System.Globalization.CultureInfo.InvariantCulture)(EventDTOMapper.cs:37-41). BecauseEventDTOis a record, thewithcopy 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-lineNullableEmailToStringhelper 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 sameRoomDTOshape 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) andUpdateEventHandler(MMCA.ADC.Conference.Application/Events/UseCases/Update/UpdateEventHandler.cs:20), and resolved asIEntityDTOMapper<Event, EventDTO, EventIdentifierType>by the event query service (MMCA.ADC.Conference.Application/DependencyInjection.cs:69, constructor parameter atMMCA.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 byEventDTOMapperTests.
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>fromMMCA.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), theCategoryaggregate and itsCategoryItemchild (UpdateCategoryItemHandler.cs:2), andResult(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 implementICommandHandler<,>itself: a real handler returns one of three shapes (a bareResultfor verb-style commands, aResult<T>carrying the refreshed DTO, aResult<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-Resultone (MutateEntityHandlerBase.cs:320-333). The choice is visible in the endpoint: a category-item update answers204 No Content(MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:175), so there is nothing to map back. Compare this handler line by line withRemoveCategoryItemHandler: identical primary constructor, identicalIncludes, identicalEntityId, and the only structural difference is that the remove overridesLoadAsyncand 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 insideCategory.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
IUnitOfWorkandILogger<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.MutateAsyncis 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 withTask.FromResult(MutateEntityHandlerBase.cs:100-106).LogMutatedcalls 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 throughLoadAsync, whose default isGetByIdAsync(EntityId(command), Includes, AsTracking, ...)withAsTrackingdefaulting totruebecause a no-tracking load would turn the mutation into a silent no-op (MutateEntityHandlerBase.cs:72-76,152-160), fail withError.NotFound.WithSource(HandlerName).WithTarget(typeof(TEntity).Name)when the row is absent (MutateEntityHandlerBase.cs:282-283,HandlerNamedefaulting to the concrete type's own name at:62), skip the row-version stamp becauseRowVersion(command)isnullfor 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 ownUpdateapplies the values after re-checking the name invariant (Category.cs:172;MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:73), and only then isCategoryItemChangedwithDomainEntityState.Updatedqueued (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:
SetOriginalRowVersionis applied by the base for any command that reports a token, which is how a conditional endpoint gets412 Precondition Failedrather than last-write-wins (ADR-035,MutateEntityHandlerBase.cs:285-292). - Where it's used: resolved by the
CategoryItemsControllerasICommandHandler<UpdateCategoryItemCommand, Result>(MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:66) and invoked atCategoryItemsController.cs:161-167, followed by an explicit eviction of theconference:categoriesandconferenceoutput-cache tags and a204 No Content(CategoryItemsController.cs:174-175). Covered byUpdateCategoryItemHandlerTests(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 isUpdateCategoryItemHandlerplus 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>fromMMCA.Common.Application.UseCases(RemoveCategoryItemHandler.cs:4,16),IUnitOfWork(RemoveCategoryItemHandler.cs:3,14),IRepository<TEntity, TIdentifierType>as theLoadAsyncparameter (RemoveCategoryItemHandler.cs:26),ILogger<T>with a source-generated[LoggerMessage](RemoveCategoryItemHandler.cs:1,15,62-63), theCategoryaggregate and itsCategoryItemchild (RemoveCategoryItemHandler.cs:2), andResult(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-
Resultmutate base whose only job is to makeIncludesabstractinstead of virtual, because a remove that cannot see the child collection cannot find the child and reports a misleadingNotFound(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:129-152). The interesting part is theLoadAsyncoverride. 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 takescategoryIdas a plain[FromQuery]argument (MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:182) and the UI's generic delete sends only the item id, soCategoryIdarrives asdefault(0). The handler branches on that (RemoveCategoryItemHandler.cs:35): when the id is unset it callsrepository.FirstOrDefaultAsyncwith 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 passincludes: IncludesandasTracking: AsTracking, and both are load-bearing: the predicate overload ofFirstOrDefaultAsyncdefaultsasTrackingtofalse(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 atSaveChangesAsync.[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 whatFirstOrDefaultAsyncexists 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
IUnitOfWorkandILogger<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.CategoryIdsupplies the key for the by-id branch (RemoveCategoryItemHandler.cs:22), and theLoadAsyncoverride holds the two-branch lookup described above with anArgumentNullException.ThrowIfNull(repository)guard first (RemoveCategoryItemHandler.cs:25-49).MutateAsyncis again one line,Task.FromResult(entity.RemoveCategoryItem(command.CategoryItemId))(RemoveCategoryItemHandler.cs:52-56), andLogMutatedcalls the generatedLogCategoryItemRemovedpartial (RemoveCategoryItemHandler.cs:59-60, template at:62-63). Everything else is the inherited sequence: not-found becomesError.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 fromRemoveChildOrNotFound(MMCA.ADC.Conference.Domain/Categories/Category.cs:188-191) reaches the caller with its own error intact; and only a successful mutation reaches the singleSaveChangesAsync(MutateEntityHandlerBase.cs:294-303). Inside the aggregate the removal is a soft delete on the child performed by the framework'sRemoveChildOrNotFoundhelper, followed by aCategoryItemChangedevent withDomainEntityState.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
SaveChangesAsyncstays 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 aLoadAsyncoverride 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
CategoryItemsControllerasICommandHandler<RemoveCategoryItemCommand, Result>(MMCA.ADC.Conference.API/Controllers/Categories/CategoryItemsController.cs:67) and invoked atCategoryItemsController.cs:185-187, after which the controller evicts theconference:categoriesandconferenceoutput-cache tags and returns204 No Content(CategoryItemsController.cs:194-195). Covered byRemoveCategoryItemHandlerTests(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:
ICacheInvalidatingfromMMCA.Common.Application.UseCases(AddEventQuestionAnswerCommand.cs:2,15), theEventdomain type used only to build the cache prefix (:1,18), and three identifier aliases,EventIdentifierType,EventQuestionAnswerIdentifierType, andQuestionIdentifierType(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 declaresEventQuestionAnswer.Create(EventQuestionAnswerIdentifierType? id, ...)(MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:46-49) and resolves it asId = isIdValueGenerated ? default : id!.Value(EventQuestionAnswer.cs:60). Because the entity carries[IdValueGenerated](EventQuestionAnswer.cs:12), a null id becomes0and 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 isstring 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 readsCachePrefixoff the command rather than the handler purging by hand. - Walkthrough: a
sealed recordwith four positional parameters (AddEventQuestionAnswerCommand.cs:11-15) implementingICacheInvalidating(:15). The single body member isCachePrefix => $"{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 fromICurrentUserServiceinsideAddEventQuestionAnswerHandler(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.AddEventQuestionAnswerneeds (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 anAddEventQuestionAnswerRequestbody (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:146,150, handler injected at:57), validated byAddEventQuestionAnswerCommandValidator, and handled byAddEventQuestionAnswerHandler. The controller always passesnullfor 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
EventSpeakerjoin row under theEventaggregate. - Depends on:
ICacheInvalidating(AddEventSpeakerCommand.cs:2,13), theEventtype for the cache prefix (:1,16), and the aliasesEventIdentifierType(= int),EventSpeakerIdentifierType(= int), andSpeakerIdentifierType(= 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 anintand the speaker is aGuid, because speakers carry Sessionize-assigned GUIDs (BR-61, noted atMMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3) while the join row itself is database-generated ([IdValueGenerated]onMMCA.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 optionalEventSpeakerId, andSpeakerId(AddEventSpeakerCommand.cs:10-13), plusCachePrefix => $"{typeof(Event).FullName}:"(:16). Duplicate protection is not on this record and not in its validator: the aggregate refuses a second live association withError.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 anAddEventSpeakerRequestbody (MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:165,169, handler injected at:49), validated byAddEventSpeakerCommandValidator, and handled byAddEventSpeakerHandler.
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
Roomto 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), theEventtype for the cache prefix (:1,26), and theEventIdentifierType/RoomIdentifierTypealiases (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].Roomdoes 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, andRoomIdentifierType? RoomId(AddRoomCommand.cs:17) becomes an instruction toAddRoomHandlerto 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), implementingICacheInvalidating(:23), withCachePrefix => $"{typeof(Event).FullName}:"(:26) keyed on the aggregate root rather than onRoom.NameandSortare 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 theIX_Room_EventId_Nameindex 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
AddRoomRequestbody (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:207,211, handler injected at:94), validated byAddRoomCommandValidator, and handled byAddRoomHandler. Its edit and delete counterparts areUpdateRoomCommandandRemoveRoomCommand.
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 separateCreateEventCommand. - Depends on:
ICreateRequestandICacheInvalidatingfromMMCA.Common.Application.Interfacesand.UseCases(EventCreateRequest.cs:3-4,11), the module-localIEventFieldsRequest(:1,11), theEventtype for the cache prefix (:2,14), theEventIdentifierTypealias (= int), andSystem.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 letsEventFieldRules<T>be constrainedwhere 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 inEventFieldRules<T>, and both operations follow.[Rubric §9, API and Contract Design]: exactly four members arerequired(Name,StartDate,EndDate,TimeZone,:20,26,29,32) and nine are optional by declaration.[Rubric §12, Performance & Scalability]: eviction is declared throughCachePrefix, 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-nullableEventIdentifierType, so an omitted id binds to0; that is harmless here becauseEventcarries[IdValueGenerated](MMCA.ADC.Conference.Domain/Events/Event.cs:23) and the factory resolves the key asId = isIdValueGenerated ? default : id!.Value(Event.cs:203), which means the factory'sid!.Valuebranch is unreachable for this aggregate. The fourrequiredmembers (:20,26,29,32) are the ones the domain factory guards,EnsureNameIsValid,EnsureTimeZoneIsValid, andEnsureDateRangeIsValid(Event.cs:180-183). The nine optional members are the edition's publishable extras:Description,SessionizeCode,VenueAddress,VenueMapUrl,WiFiInfo,OrganizerContactEmail,SponsorshipPacketUrl, andTicketingUrl(:23,35,38,41,44,47,50,53). Every member isinit-only, so model binding is the last chance to set anything. Note the type declaration ispublic record classrather thansealed 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 asICommandHandler<EventCreateRequest, Result<EventDTO>>(:48); validated byEventCreateRequestValidator, converted byEventCreateRequestMapper, and handled byCreateEventHandler. Its edit-side counterpart isEventUpdateRequest.
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), theEventtype for the cache prefix (:1,16), theEventIdentifierTypealias (= int), andbyte[](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'sIf-Matchheader, 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 withSupportsIfMatchAttribute.RequiredToken(HttpContext)(:272).PublishEventHandlerreports it back to the framework through itsRowVersionoverride (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 recordwith two positional parameters (PublishEventCommand.cs:13) plusCachePrefix => $"{typeof(Event).FullName}:"(:16). There is noPublishEventCommandValidator, 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 asICommandHandler<PublishEventCommand, Result>at:50) and handled byPublishEventHandler. Its mirror isUnpublishEventCommand.
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
AddEventQuestionAnswerCommandbefore 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
AnswerValueis 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, withAnswerValueMaxLengthat:58). Whether the answer's shape is legal depends on the question being answered, soEnsureAnswerValueMatchesQuestionType(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 codeEventQuestionAnswer.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 aboutEventQuestionAnswerId(there is nothing to validate about an absent id) or aboutEventIdandQuestionId(a wrong id answersNotFoundfrom 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
newin 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 byAddEventQuestionAnswerCommandValidatorTests(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),EventQuestionAnswerDTOMapperinjected by concrete type (:21),TimeProvider(BCL,:22),ILogger<T>(:23), theEventandQuestionaggregates,EventInvariantsandQuestionInvariants, theEventFeedbackSubmittedintegration event (:6,112), andICommandHandler<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 (
AddEventSpeakerHandleris the clean example). This one implementsICommandHandler<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 sameSaveChangesAsynctransaction 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 returnsError.NotFoundtagged with the handler and entity names (:36-37). (2) BR-108, the event must be published, viaEventInvariants.EnsureEventIsPublished(:40-42, invariant atMMCA.ADC.Conference.Domain/Events/EventInvariants.cs:151-157). (3)ValidateQuestionAsync(:60-79) covers two rules in one pass: BR-128 loads theQuestionfrom its own repository and rejects it unlessquestion.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 fromcurrentUserService.UserId!.Value(:50) and the existing answer is found in memory withFirstOrDefault(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) callsentity.UpdateEventQuestionAnswer, saves, logs, and maps the existing child;CreateNewAnswerAsync(:96-117) callsentity.AddEventQuestionAnswer, raises the integration event, saves, logs, and maps the new child. Note the asymmetry, stated in the code comment at:109-111:EventFeedbackSubmittedis 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 ispartial. - 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 byAddEventQuestionAnswerHandlerTests(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 theSpeakerIdentifierTypealias (= System.Guid). - Concept: the alias-aware default check.
NotEqual(default(SpeakerIdentifierType))(:12) reads as a type-agnostic rule, but it resolves toGuid.Emptybecause the alias isSystem.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 namingGuidhere. This matters more than for the int-keyed commands: an omittedGuidbinds toGuid.Emptyrather 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) withRuleFor(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) whereAddEventQuestionAnswerCommandValidatorreports the stableEventQuestionAnswer.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 byAddEventSpeakerCommandValidatorTests(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>fromMMCA.Common.Application.UseCases(AddEventSpeakerHandler.cs:19),IUnitOfWork(:16),EventSpeakerDTOMapperinjected by concrete type (:17), andILogger<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
NotFoundif 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 withAddEventQuestionAnswerHandler'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 fiveprotected overridemembers 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 declaresIncludesabstract (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.ParentIdreadscommand.EventId(:27).Applycallsparent.AddEventSpeaker(command.EventSpeakerId, command.SpeakerId)(:30-31), the aggregate method that owns the duplicate rule and raisesEventSpeakerChanged(MMCA.ADC.Conference.Domain/Events/Event.cs:536,557).MapChilddelegates to the Mapperly mapper (:34).LogAddedforwards to the source-generated[LoggerMessage]partial (:37-41), which is why the class ispartial. Tracking is left at the base default oftrue(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 byAddEventSpeakerHandlerTests(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 sixIncludecalls, one per room field. - Depends on:
FluentValidation.AbstractValidator<T>(NuGet,AddRoomCommandValidator.cs:1,7) and the six generic rule objects fromMMCA.ADC.Conference.Application.Events.Validation(:2,11-16):RoomNameRules<T>,RoomSortRules<T>,RoomCapacityRules<T>,RoomFloorRules<T>,RoomLocationRules<T>, andRoomAccessibilityInfoRules<T>. - Concept: rule composition by
Includewith a property selector, the mechanism the module's rule objects introduce, at its widest use in this unit. Each rule is generic inTand takes anExpression<Func<T, ...>>, sonew RoomNameRules<AddRoomCommand>(p => p.Name)means "apply the room-name rules to this type'sNameproperty", 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 exampleRoom.Name.RequiredandRoom.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'sRequiredStringRules<T>and enforces non-empty plus max length (RoomValidationRules.cs:13-20), andRoomSortRules<T>derives fromNonNegativeIntRules<T>with the codeRoom.Sort.Negative(:26-32). Four are conditional:RoomCapacityRules<T>appliesGreaterThan(0)with codeRoom.Capacity.NotPositiveonly.When(...)the value is not null (:37-45), and the three optional strings derive fromOptionalStringRules<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 byAddRoomCommandValidatorTests(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
EventCreateRequestand theEventdomain entity. It does not construct the entity itself: it calls the aggregate's factory and hands back whateverResult<T>that factory returns. - Depends on:
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>fromMMCA.Common.Application.Interfaces(EventCreateRequestMapper.cs:2,12), theEventaggregate (:1),Result(:3), and theEventIdentifierTypealias. - 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 ifEnsureNameIsValid,EnsureTimeZoneIsValid, andEnsureDateRangeIsValidall 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 nonew 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 withArgumentNullException.ThrowIfNull(request)(:17), then returnsTask.FromResult(Event.Create(...))(:19-32): the work is synchronous, and theTaskexists 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 ofQuestionModerationDefault.Pending(MMCA.ADC.Conference.Domain/Events/Event.cs:181). That is the same asymmetryIEventFieldsRequestrecords: 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
CreateEventHandlerasIEntityRequestMapper<Event, EventCreateRequest, EventIdentifierType>(MMCA.ADC.Conference.Application/Events/UseCases/Create/CreateEventHandler.cs:17) and consumed by the framework's create workflow atrequestMapper.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 explicitAddScopedline.
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 singleInclude. - Depends on:
FluentValidation.AbstractValidator<T>(NuGet,EventCreateRequestValidator.cs:1,7) andEventFieldRules<T>(:2,10). - Concept: rule composition through the shared-field interface introduced by
EventCreateRequest. BecauseEventFieldRules<T>is constrainedwhere T : IEventFieldsRequestand 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 viaRequiredStringRules<T>(:13-18, limit atMMCA.ADC.Conference.Shared/Events/EventDTO.cs:19); time zone required, max length 100, and aMust(BeAValidIanaTimeZone)check that resolves the identifier throughTimeZoneInfo.FindSystemTimeZoneByIdand reportsEvent.TimeZone.InvalidIanaon aTimeZoneNotFoundException(:25-48, BR-87); a date-range rule requiring both dates and assertingendDate >= startDatewith codeEvent.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 onlyWhena 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 executablejavascript:ordata: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 byEventCreateRequestValidatorTests(Group 27,MMCA.ADC.Conference.Application.Tests/Events/Validation/EventCreateRequestValidatorTests.cs). Its edit-side counterpart isEventUpdateRequestValidator.
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),EventCreateRequestMapperresolved through its interface (:17),EventDTOMapperinjected by concrete type (:18), andILogger<T>(:19). - Concept: the create counterpart of the template-method base
AddEventSpeakerHandlerdemonstrates.CreateCoreAsyncruns the fixed sequence (MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:77-103): the optionalPrepareAsyncpre-map step,requestMapper.CreateEntityAsync(:89),PersistAsyncwhich adds and saves (:96,128-145),LogCreated(:98), theOnCreatedAsyncpost-commit hook (:99), and finallydtoMapper.MapToDTO(entity)(:101). Three of those five hooks arevirtualwith working defaults, so a create slice with no special needs overrides exactly one. Note the generic constraints (CreateEntityHandlerBase.cs:46-49): the request must implementICreateRequest, the entity must be anAuditableAggregateRootEntity<TIdentifierType>, and the DTO must implementIBaseDTO<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]:LogCreatedis 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, anddtoMapperto the base (CreateEventHandler.cs:15-21). The only member isLogCreated, which forwards to the source-generated[LoggerMessage]partialLogEventCreatedwith the id and name (:24-27), so the emitted event is structured and allocation-free rather than an interpolated string.PrepareAsyncandPersistAsyncare left at their defaults: an event needs no app-assigned key (unlikeAddRoomHandler) and no non-standard persist step. Audit stamping and soft-delete are not visible here at all; they are applied byApplicationDbContext.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 byCreateEventHandlerTests(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 theEventaggregate. - Concept introduced, the verb-command handler that answers with a bare
Result. The three-parameterMutateEntityHandlerBaseis 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, returnsError.NotFoundif it is absent (:279-282), stamps the optimistic-concurrency token if the handler reports one (:290-291), runsMutateAsync, 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 reportsnullfrom the base default (:90) and the stamp is skipped, while a handler on a conditional endpoint always has a token, because a request without anIf-Matchheader 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.
EntityIdreadscommand.Id(PublishEventHandler.cs:19).RowVersionreturnscommand.RowVersion(:25), the single line that turns this into a conditional write, with the reason stated in the comment above it (:21-22).MutateAsyncreturnsTask.FromResult(entity.Publish())(:28-32), delegating the entire decision to the aggregate, which refuses a second publish withEvent.AlreadyPublishedand otherwise setsIsPublishedand raisesEventChanged(MMCA.ADC.Conference.Domain/Events/Event.cs:299-315).LogMutatedforwards to the[LoggerMessage]partial (:35-39).Includesis 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
RowVersionoverrides. - 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 theconference:eventsoutput-cache tag after a success (:282); registered by the convention scan (MMCA.ADC.Conference.Application/DependencyInjection.cs:133); covered byPublishEventHandlerTests(Group 27). Its mirror isUnpublishEventHandler.
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
Roomto 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),IServiceScopeFactoryfromMicrosoft.Extensions.DependencyInjection(:1,25),RoomDTOMapperinjected by concrete type (:26),ILogger<T>(:27),MutationContext,IEntityQuerier<TEntity, TIdentifierType>(:128), andEventInvariantsfor the reserved id range. - Concept introduced, the application-assigned key and the retry that it forces. Follow the chain from
AddRoomCommand:Roomhas 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_000andRoomManualIdRangeEnd = 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 ambientDbContext: 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 ofMutateCoreAsync(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,ignoreQueryFiltersread over the reserved band only (:130-134), so it never scans the full room table. - Walkthrough (fields, then the loop, then the hooks). Four
constvalues carry the policy:MaxManualIdAttempts = 3(:31),RoomNameIndexName = "IX_Room_EventId_Name"(:37), and two context keys,AttemptUnitOfWorkKeyandAddedRoomKey(:44,47), each with a comment stating why it exists.EntityIdreadscommand.EventId(:50) andIncludes => [nameof(Event.Rooms)](:56) is load-bearing for the same reason as inAddEventSpeakerHandler: 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 awhile (true)loop runs attempt 1 against the injected unit of work (:74-75) and every later attempt insideawait 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-raiseRoomChanged(:77-81). Thecatchfilter is narrow:when (attempt < MaxManualIdAttempts && IsUniqueKeyViolation(ex))(:86).AddRoomAttemptAsync(:98-111) creates theMutationContext, stashes the attempt's unit of work in it, callsMutateCoreAsync, 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 whencommand.RoomId is null(:126-144) and fails withRoom.ManualIdRangeExhaustedif the band is full (:140-141), callsentity.AddRoom(...)(:146-153), and stores the new room underAddedRoomKey(:157).BuildResultmaps that stored room to aRoomDTO(:167-168).IsUniqueKeyViolation(:178-190) walks the wholeInnerExceptionchain 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 byAddRoomHandlerTests(Group 27). The Sessionize import does not go through this handler:RoomSyncStrategycalls 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
Questionaggregate into theQuestionDTOthe 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 overQuestion,QuestionDTOand theQuestionIdentifierTypealias (QuestionDTOMapper.cs:13); theQuestionentity and theQuestionDTOcontract (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: thepartialdeclaration atQuestionDTOMapper.cs:16is 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*RequestMapperclasses such asQuestionCreateRequestMapper, 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 issealed 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,IsRequiredandQuestionSourceon 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 theIdandRowVersionmembers the DTO declares forIBaseDTO<QuestionIdentifierType>andIConcurrencyAware(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,QuestionTypeandQuestionSourceare 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) andUpdateQuestionHandler(UpdateQuestionHandler.cs:21); resolved through its interface byEntityQueryService<TEntity, TEntityDTO, TIdentifierType>, registered forQuestionatMMCA.ADC.Conference.Application/DependencyInjection.cs:86, which drives every read onQuestionsController. The mapper itself is picked up by the module scan (DependencyInjection.cs:130). Covered byQuestionDTOMapperTests. - 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
EventQuestionAnswerfrom anEvent. Two positional parameters, the owningEventIdand theEventQuestionAnswerId(MMCA.ADC.Conference.Application/Events/UseCases/RemoveEventQuestionAnswer/RemoveEventQuestionAnswerCommand.cs:9-11). - Depends on:
ICacheInvalidating(RemoveEventQuestionAnswerCommand.cs:2,RemoveEventQuestionAnswerCommand.cs:11), theEventtype for the cache prefix (RemoveEventQuestionAnswerCommand.cs:1,RemoveEventQuestionAnswerCommand.cs:14), and theEventIdentifierType/EventQuestionAnswerIdentifierTypealiases (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
EventQuestionAnswerChangeddomain event fire, so the handler loads theEventand calls a method on it rather than deleting a row by id. The removal itself is a soft delete: the framework'sRemoveChildOrNotFoundresolves the active child and calls itsDelete()(MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:163,AuditableAggregateRootEntity.cs:171), which setsIsDeleted = trueand fails withError.AlreadyDeletedif 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 recordwithEventIdandEventQuestionAnswerId(RemoveEventQuestionAnswerCommand.cs:9-11), plusCachePrefix => $"{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 withCancellationToken.Noneso 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
ExecuteDeletepreserves 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.DeleteAsyncasnew 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 atEventQuestionAnswersController.cs:59). Handled byRemoveEventQuestionAnswerHandler, which adds the ownership rule the record does not express. - Caveats / not-in-source: unlike
UnpublishEventCommand, this command carries noRowVersion, 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
EventSpeakerassociation 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), theEventtype for the cache prefix (RemoveEventSpeakerCommand.cs:1,RemoveEventSpeakerCommand.cs:14), and theEventIdentifierType/EventSpeakerIdentifierTypealiases. - 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) withCachePrefix => $"{typeof(Event).FullName}:"(RemoveEventSpeakerCommand.cs:14). - Where it's used: constructed by
EventSpeakersController.DeleteAsyncasnew RemoveEventSpeakerCommand(eventId, id)(MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:186-193, handler injected atEventSpeakersController.cs:50); the same action then evicts three output-cache tags,conference:events,conference:speakersandconference(EventSpeakersController.cs:200). Handled byRemoveEventSpeakerHandler; its add-side counterpart isAddEventSpeakerCommand.
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
Roomfrom an event: the owningEventIdand theRoomId(MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomCommand.cs:9-11). - Depends on:
ICacheInvalidating(RemoveRoomCommand.cs:2,RemoveRoomCommand.cs:11), theEventtype for the cache prefix (RemoveRoomCommand.cs:1,RemoveRoomCommand.cs:14), and theEventIdentifierType/RoomIdentifierTypealiases. - 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) withCachePrefix => $"{typeof(Event).FullName}:"(RemoveRoomCommand.cs:14). - Where it's used: constructed by
RoomsController.DeleteAsyncasnew RemoveRoomCommand(eventId, id)(MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:260-267, handler injected atRoomsController.cs:96), which evicts theconference:roomsoutput-cache tag and returns204 No Content(RoomsController.cs:272-273). Handled byRemoveRoomHandler; its add-side counterpart isAddRoomCommand.
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), theEventtype for the cache prefix (UnpublishEventCommand.cs:1,UnpublishEventCommand.cs:16), theEventIdentifierTypealias, andbyte[]from the BCL. - Concept introduced, the conditional transition command.
RowVersionis declared as a non-nullablebyte[], 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'sIf-Matchheader and a transition that states no precondition never reaches this command (UnpublishEventCommand.cs:8-12). That is enforced at the boundary rather than here:SupportsIfMatchAttributeanswers a missing header with428 Precondition Requiredbefore the action body runs (MMCA.Common.API/Concurrency/SupportsIfMatchAttribute.cs:165) and rewrites a downstream conflict to412 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 anIsPublished = falsemutation, 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) withCachePrefix => $"{typeof(Event).FullName}:"(UnpublishEventCommand.cs:16). Apart from the name, it is identical toPublishEventCommand(MMCA.ADC.Conference.Application/Events/UseCases/Publish/PublishEventCommand.cs:13-17). - Where it's used: constructed by
EventsController.UnpublishAsyncasnew UnpublishEventCommand(id, rowVersion), whererowVersioncomes fromSupportsIfMatchAttribute.RequiredToken(HttpContext)(MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:306-310, handler injected atEventsController.cs:51). ThePOST {id}/unpublishendpoint carries[Idempotent]and[SupportsIfMatch]and declares 409, 412 and 428 (EventsController.cs:295-300); on success it evicts theconference:eventsoutput-cache tag and returns204 No Content(EventsController.cs:314-315). Handled byUnpublishEventHandler.
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, theEventQuestionAnswerIdand the newAnswerValue(MMCA.ADC.Conference.Application/Events/UseCases/UpdateEventQuestionAnswer/UpdateEventQuestionAnswerCommand.cs:10-13). - Depends on:
ICacheInvalidating(UpdateEventQuestionAnswerCommand.cs:2,UpdateEventQuestionAnswerCommand.cs:13), theEventtype for the cache prefix (UpdateEventQuestionAnswerCommand.cs:1,UpdateEventQuestionAnswerCommand.cs:16), and theEventIdentifierType/EventQuestionAnswerIdentifierTypealiases. - 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
UpdateEventQuestionAnswerHandlerfromICurrentUserService, 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) withCachePrefix => $"{typeof(Event).FullName}:"(UpdateEventQuestionAnswerCommand.cs:16). The payload rules are enforced twice on the way down:UpdateEventQuestionAnswerCommandValidatorrejects an empty or over-long value at the boundary, andEventInvariants.EnsureAnswerValueIsValidruns again insideEventQuestionAnswer.UpdateAnswer(MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:73). - Where it's used: constructed by
EventQuestionAnswersController.UpdateAsyncasnew UpdateEventQuestionAnswerCommand(request.EventId, id, request.AnswerValue), taking the event id from theUpdateEventQuestionAnswerRequestbody and the answer id from the route (MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:163-174, handler injected atEventQuestionAnswersController.cs:58). Handled byUpdateEventQuestionAnswerHandler.
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); theEventtype, referenced only for the cache prefix (UpdateRoomCommand.cs:1,UpdateRoomCommand.cs:26); theEventIdentifierTypeandRoomIdentifierTypealiases. - 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 whyRoomsControllerbinds it from a completeUpdateRoomRequestbody (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 recordwith eight positional parameters (UpdateRoomCommand.cs:15-23), each documented individually (UpdateRoomCommand.cs:7-14), and the singleCachePrefixmember keyed on theEventtype 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 underEvent. Note what is absent: noRowVersion, so unlikeUnpublishEventCommanda room edit is last-write-wins. - Where it's used: constructed by
RoomsController'sPUT /Rooms/{id}action (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:234-249, handler injected atRoomsController.cs:95), which evicts theconference:roomsoutput-cache tag and returns204 No Contenton success (RoomsController.cs:254-255); validated byUpdateRoomCommandValidator; handled byUpdateRoomHandler.
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 onAnswerValue: 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) andQuestionInvariantsfor 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.RequiredandEventQuestionAnswer.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 is2000(MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:28), the identical valueValidateTextAnswerenforces 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 byValidatingCommandDecorator<TCommand, TResult>ahead ofUpdateEventQuestionAnswerHandler. - 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 theNotEmptyrule 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 byUpdateEventQuestionAnswerHandler; on the update path the deepest remaining guard isEventInvariants.EnsureAnswerValueIsValid, whose ceiling is the widerAnswerValueMaxLengthof 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>andRoomAccessibilityInfoRules<T>from the module'sEvents.Validationfolder (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, bothIncludethe same generic rule sets, parameterized by a property selector.Includemerges 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 exampleRoom.Name.RequiredandRoom.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,RoomLocationMaxLengthandRoomAccessibilityInfoMaxLength(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 classwhose 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>subclassesNonNegativeIntRules<T>with the codeRoom.Sort.Negative(RoomValidationRules.cs:26-31), andRoomFloorRules<T>,RoomLocationRules<T>andRoomAccessibilityInfoRules<T>each subclassOptionalStringRules<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 areRoomNameRules<T>, required plus max length (RoomValidationRules.cs:13-19), andRoomCapacityRules<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
AddRoomCommandValidatorincludes the identical six. Running them ahead of the transaction is the pipeline's job:ValidatingCommandDecorator<TCommand, TResult>sits outsideTransactionalCommandDecorator<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 ofUpdateRoomHandler. Covered byUpdateRoomCommandValidatorTests(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
EnsureRoomNameIsUniqueand comes back as the invariant errorEvent.Room.Duplicate(MMCA.ADC.Conference.Domain/Events/Event.cs:695-712, code atEvent.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,EventandEventIdentifierType(RemoveEventQuestionAnswerHandler.cs:25);IUnitOfWork(RemoveEventQuestionAnswerHandler.cs:22);ICurrentUserService(:22);ILogger<RemoveEventQuestionAnswerHandler>(:23);RoleNamesfromMMCA.Common.Shared.Auth(:6,:43); theEventQuestionAnswerchild type; andResult/Error. - Concept introduced, the aggregate-write template method. No handler in this unit writes a
HandleAsyncbody. The shared workflow lives once inMutateEntityHandlerCore<TCommand, TEntity, TIdentifierType>and runs six fixed steps: resolve the repository from the unit of work, load the aggregate through the overridableLoadAsync, fail withError.NotFoundsourced to the handler's own type name when it is gone, stamp the caller'sRowVersionas the tracked entity's original value when the command reports one, runMutateAsync, 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 theICommandHandler<TCommand, Result>implementation, flattening the workflow'sResult<TEntity>to a bareResult(MutateEntityHandlerBase.cs:326-331). A concrete handler therefore supplies overrides, not control flow:EntityIdis the one abstract member (MutateEntityHandlerBase.cs:80),Includesdefaults to empty (:69),AsTrackingdefaults totruebecause a no-tracking load would make the mutation a silent no-op (:75),RowVersiondefaults tonullfor an unconditional endpoint (:90), andLogMutatedis 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 theOrganizerrole, andanswer.CreatedBy != currentUserService.UserId!.Value.CreatedByis 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 returnsError.Forbiddenwith the codeEventQuestionAnswer.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 withFirstOrDefault(a => a.Id == command.EventQuestionAnswerId && !a.IsDeleted)(:42), applies the ownership gate (:43-50), and otherwise returnsentity.RemoveEventQuestionAnswer(command.EventQuestionAnswerId)wrapped inTask.FromResult(:52). The aggregate resolves the child through the sharedRemoveChildOrNotFoundhelper, soft-deletes it and raisesEventQuestionAnswerChangedwithDomainEntityState.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-generatedLogQuestionAnswerRemovedFromEvent(: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.
- Primary constructor and base call (
- Why it's built this way: putting the gate inside
MutateAsyncrather than before the load is what makes it correct. The check needs the persistedCreatedBy, and a refusal there short-circuits the workflow beforeSaveChangesAsyncis 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 byEventQuestionAnswersController.DeleteAsync(MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:59,EventQuestionAnswersController.cs:179-190), which returns204 No Contenton success. Covered byRemoveEventQuestionAnswerHandlerTests. - Caveats / not-in-source: this remove derives from the general
MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>while its two siblings below useRemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType>; nothing in source states why. The two bases differ only in that the child base makesIncludesa 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 ofPublishEventHandlerwithentity.Unpublish()in place ofentity.Publish(). - Depends on:
MutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>closed over the command,EventandEventIdentifierType(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 callsrepository.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 theRowVersionproperty's original value (MMCA.Common.Infrastructure/Persistence/Repositories/EFRepository.cs:75-83), so the UPDATE EF emits carriesWHERE RowVersion = @clientToken. If someone else touched the row, zero rows match, EF raisesDbUpdateConcurrencyException, and because the endpoint is decorated withSupportsIfMatchAttributethe resulting conflict is rewritten to412 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); theRowVersionoverride (:25);MutateAsyncreturningTask.FromResult(entity.Unpublish())(:28-32);LogMutatedforwarding to the generatedLogEventUnpublished(:35-36, declared:38-39). NoIncludesoverride, 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.Unpublishfails with the invariantEvent.AlreadyUnpublishedwhen the flag is already clear, otherwise setsIsPublished = falseand raisesEventChangedwithDomainEntityState.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
Resultunchanged preserves theEvent.AlreadyUnpublishedcode 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 theconference:eventsoutput-cache tag and returns204 No Contenton success (EventsController.cs:314-315). Covered byUnpublishEventHandlerTests. - 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
CachePrefixonUnpublishEventCommand(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,EventandEventIdentifierType(UpdateEventQuestionAnswerHandler.cs:25);IUnitOfWork(:21);ICurrentUserService(:22);ILogger<UpdateEventQuestionAnswerHandler>(:23);RoleNames(:6,:43); theEventQuestionAnswerchild type;Result/Error. - Concept: nothing new; the template method taught by
RemoveEventQuestionAnswerHandlerand 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 returningError.Forbiddenwith the codeEventQuestionAnswer.NotOwner(:43-50), then returnsentity.UpdateEventQuestionAnswer(command.EventQuestionAnswerId, command.AnswerValue)(:52-54). The aggregate re-resolves the child throughGetEventQuestionAnswerOrNotFound(MMCA.ADC.Conference.Domain/Events/Event.cs:647, helper atEvent.cs:692-695), delegates toanswer.UpdateAnswer(...)which runsEventInvariants.EnsureAnswerValueIsValidbefore assigning (MMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:71-80), and raisesEventQuestionAnswerChangedwithDomainEntityState.Updatedonly after both pass (Event.cs:629).LogMutated(:58-59) fires only after the commit, through the generatedLogEventQuestionAnswerUpdated(: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 byEventQuestionAnswersController.UpdateAsynconPUT {id}(MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:58,EventQuestionAnswersController.cs:163-174), which returns204 No Contenton success. Covered byUpdateEventQuestionAnswerHandlerTests. - Caveats / not-in-source: the type-specific half of BR-124 is not applied on this path.
QuestionInvariants.EnsureAnswerValueMatchesQuestionTypeis 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 inUpdateEventQuestionAnswerCommandValidatorand 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'sLastModifiedBystamp 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,EventandEventIdentifierType(MMCA.ADC.Conference.Application/Events/UseCases/UpdateRoom/UpdateRoomHandler.cs:16);IUnitOfWork(:14);ILogger<UpdateRoomHandler>(:15); theRoomchild type;Result. No DTO mapper is injected, because the update returns no body: the base flattens the workflow to a bareResult(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 nothingUpdateRoomCommandValidatoralready 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 toroom.Update(...)for the field-level invariants (Event.cs:413-415), and raisesRoomChangedwithDomainEntityState.Updatedonly 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);MutateAsyncforwarding the six fields positionally toentity.UpdateRoom(...)insideTask.FromResult(:25-36);LogMutatedcalling the generatedLogRoomUpdatedwith both ids (:39-40, declared:42-43). - Why it's built this way: the uniqueness rule is the reason the whole
Roomscollection 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 typedEvent.Room.Duplicateerror (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 byRoomsController'sPUT /Rooms/{id}action (MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:95,RoomsController.cs:234-256), which returns204 No Contentand evicts theconference:roomsoutput cache on success. Covered byUpdateRoomHandlerTests. - Caveats / not-in-source: no
RowVersionoverride, 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,EventandEventIdentifierType(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 fromMutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>and re-declaresIncludesasabstract 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 requiredIncludes => [nameof(Event.EventSpeakers)](:19);EntityId(command) => command.EventId(:22);MutateAsyncreturningTask.FromResult(entity.RemoveEventSpeaker(command.EventSpeakerId))(:25-29);LogMutatedcalling the generatedLogSpeakerRemovedFromEventwith the speaker and event ids (:32-33, declared:35-36). The aggregate resolves the child through the framework'sRemoveChildOrNotFound, which finds the active child, calls itsDelete()and propagates that failure if the row was already retired (MMCA.Common.Domain/Entities/AuditableAggregateRootEntity.cs:156-178), then raisesEventSpeakerChangedwithDomainEntityState.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 byEventSpeakersController.DeleteAsync(MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:50,EventSpeakersController.cs:186-200). Covered byRemoveEventSpeakerHandlerTests.
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 toRemoveEventSpeakerHandler, differing only in the include name, the aggregate method and the log message. - Depends on:
RemoveChildEntityHandlerBase<TCommand, TParent, TIdentifierType>closed over the command,EventandEventIdentifierType(MMCA.ADC.Conference.Application/Events/UseCases/RemoveRoom/RemoveRoomHandler.cs:16);IUnitOfWork(:14);ILogger<RemoveRoomHandler>(:15); theRoomchild 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 requiredIncludes => [nameof(Event.Rooms)](:19);EntityId(command) => command.EventId(:22);MutateAsyncreturningTask.FromResult(entity.RemoveRoom(command.RoomId))(:25-29);LogMutatedcalling the generatedLogRoomRemovedwith both ids (:32-33, declared:35-36).Event.RemoveRoom(MMCA.ADC.Conference.Domain/Events/Event.cs:511-521) delegates toRemoveChildOrNotFound(Event.cs:486) and raisesRoomChangedwithDomainEntityState.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 byRoomsController.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 theconference:roomsoutput cache before returning204 No Content. Covered byRemoveRoomHandlerTests. - 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 optionalSpeakerCategoryItemIdfor the join entity, and theCategoryItemIdbeing associated (AddSpeakerCategoryItemCommand.cs:13-16). - Depends on:
ICacheInvalidating, the pipeline marker it implements (AddSpeakerCategoryItemCommand.cs:16); theSpeakerdomain type, referenced only to build the cache prefix; and theSpeakerIdentifierType(aSystem.Guid),SpeakerCategoryItemIdentifierType(anint), andCategoryItemIdentifierType(anint) 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, ornullfor database-generated identity" (AddSpeakerCategoryItemCommand.cs:11). The REST path always passesnulland lets the database assign the key (SpeakerCategoryItemsControlleratMMCA.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 satisfiesICacheInvalidating, soCachingCommandDecorator<TCommand, TResult>evicts every cache entry under theSpeakerprefix 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: noITransactional, because the whole write lands in one aggregate and oneSaveChangesAsyncwith no cross-context event to keep atomic. - Where it's used: validated by
AddSpeakerCategoryItemCommandValidator, handled byAddSpeakerCategoryItemHandler, and constructed by thePOST /SpeakerCategoryItemsaction from anAddSpeakerCategoryItemRequestbody (SpeakerCategoryItemsController.cs:165-171, handler injected atSpeakerCategoryItemsController.cs:50), on a controller gated by theSpeakersManagepermission at the class level (SpeakerCategoryItemsController.cs:47, ADR-020). That action also carries[Idempotent](SpeakerCategoryItemsController.cs:164), so a retried request with the sameIdempotency-Keyreplays 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 isRemoveSpeakerCategoryItemCommand.
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:
SpeakerIdandUserId(LinkUserToSpeakerCommand.cs:13). - Depends on:
ICacheInvalidatingandITransactional, both markers implemented atLinkUserToSpeakerCommand.cs:13;Speaker, referenced only to build the cache prefix; and the module identifier aliasesSpeakerIdentifierType(aSystem.Guid,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:19) andUserIdentifierType(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. ImplementingITransactionalopts the message intoTransactionalCommandDecorator<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. ImplementingICacheInvalidatingwithCachePrefix => $"{typeof(Speaker).FullName}:"(LinkUserToSpeakerCommand.cs:16) is what makesCachingCommandDecorator<TCommand, TResult>drop every cached read keyed under theSpeakertype after a successful link. - Walkthrough: a
sealed recordwith a two-parameter positional constructor (LinkUserToSpeakerCommand.cs:13) and one member, the expression-bodiedCachePrefix(LinkUserToSpeakerCommand.cs:15-16). The cross-module identifier pairing is the notable part:UserIdentifierTypeis 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
LinkUserToSpeakerHandlerreads as pure domain orchestration (ADR-014). Records give value equality and immutability for free. - Where it's used: constructed by the
PUT /Speakers/{id}/linkaction from aLinkUserRequestbody (SpeakersControlleratMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:383, handler injected atSpeakersController.cs:52, permission-gated atSpeakersController.cs:376); handled byLinkUserToSpeakerHandler. Its inverse isUnlinkUserFromSpeakerCommand.
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:
CreateQuestionHandleris registered asICommandHandler<QuestionCreateRequest, Result<QuestionDTO>>against this type, so there is no separateCreateQuestionCommand. - Depends on:
ICreateRequest, an empty marker used as a generic constraint byIEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>and byCreateEntityHandlerBase<TCreateRequest, TEntity, TIdentifierType, TEntityDTO>;ICacheInvalidating; theQuestiontype for the cache prefix; and theQuestionIdentifierTypealias (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.Idis a settableinitmember (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, soCreateQuestionHandlerallocates from a reserved manual range and overwrites whatever arrived (CreateQuestionHandler.cs:86). CompareSpeakerCreateRequest, 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(notsealed) withinit-only members.CachePrefix => $"{typeof(Question).FullName}:"(QuestionCreateRequest.cs:13) is the invalidation tag. Exactly one member isrequired,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), andIsRequired(QuestionCreateRequest.cs:31). - Why it's built this way:
requiredplusinitgives 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 inQuestions/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 baseAggregateRootEntityControllerBase<TEntity, TEntityDTO, TIdentifierType, TCreateRequest>(QuestionsController.cs:42-43); validated byQuestionCreateRequestValidator; translated to a domain entity byQuestionCreateRequestMapper; handled byCreateQuestionHandler. - Caveats / not-in-source:
QuestionEntityandQuestionTypeare declared nullable here, butQuestion.Createtakes 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).QuestionCreateRequestMapperbridges 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
SpeakerCategoryItemjoin 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 overSpeakerCategoryItem/SpeakerCategoryItemDTO/SpeakerCategoryItemIdentifierType(SpeakerCategoryItemDTOMapper.cs:13), and theRiok.Mapperly.Abstractionspackage 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 apartialclass makes Mapperly emit the body ofpublic 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) ispartialwith 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 overSelect(MapToDTO)(SpeakerCategoryItemDTOMapper.cs:22). Re-declaring it on the class is what makes it callable through the concrete type, which matters becauseAddSpeakerCategoryItemHandlerinjects 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 intoSpeakerDTOMapperas a[UseMapper]field for the parent's child collection (SpeakerDTOMapper.cs:23-24), and resolved by the genericEntityQueryService<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 bySpeakerCategoryItemDTOMapperTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerCategoryItemDTOMapperTests.cs). - Caveats / not-in-source: what the generated
MapToDTOactually 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
QuestionCreateRequestit doubles as the command:CreateSpeakerHandleris registered asICommandHandler<SpeakerCreateRequest, Result<SpeakerDTO>>against this type, so there is no separateCreateSpeakerCommand. - Depends on: three interfaces, all declared on one line (
SpeakerCreateRequest.cs:11).ICreateRequestis the empty marker the generic request-mapper and create-handler contracts constrain on;ICacheInvalidatingdeclares the eviction; andISpeakerFieldsRequestis the module's own shape interface that lets one rule set validate both the create and the update request. It also referencesSpeakerfor the cache prefix and theSpeakerIdentifierTypealias (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.ISpeakerFieldsRequestnames 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), soSpeakerFieldRules<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:FullNamestays 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 inSpeakers/UseCases/Create. - Walkthrough: a
record class(notsealed) withinit-only members.CachePrefix(SpeakerCreateRequest.cs:13-14) is the invalidation tag.Id(SpeakerCreateRequest.cs:16-17) is aSpeakerIdentifierType, 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 arerequired, so the record cannot be constructed without them:FirstName(SpeakerCreateRequest.cs:20),LastName(SpeakerCreateRequest.cs:23), andFullName(SpeakerCreateRequest.cs:26). The rest are optionalinitmembers:Email(SpeakerCreateRequest.cs:29),Bio(SpeakerCreateRequest.cs:32),TagLine(SpeakerCreateRequest.cs:35),ProfilePicture(SpeakerCreateRequest.cs:38), theIsTopSpeakerflag (SpeakerCreateRequest.cs:41), and the four profile linksTwitterHandle(SpeakerCreateRequest.cs:44),LinkedInUrl(SpeakerCreateRequest.cs:47),GitHubUrl(SpeakerCreateRequest.cs:50), andWebsiteUrl(SpeakerCreateRequest.cs:53). - Why it's built this way:
requiredplusinitgives 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 asAddSpeakerCategoryItemCommand). - Where it's used: bound by
SpeakersControlleron theSpeakersManage-gatedPOST /Speakers(SpeakersController.cs:308-317); validated bySpeakerCreateRequestValidator; translated to a domain entity bySpeakerCreateRequestMapper; handled byCreateSpeakerHandler. Its update-side sibling on the same shape interface isSpeakerUpdateRequest. - Caveats / not-in-source:
FullNameisrequiredon the contract but never reaches the domain.SpeakercomputesFullName => $"{FirstName} {LastName}"(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:61) andSpeakerCreateRequestMapperdoes 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
SpeakerQuestionAnswertoSpeakerQuestionAnswerDTO, the speaker's answers to the conference's profile questions. Structurally identical toSpeakerCategoryItemDTOMapper: the[Mapper]attribute (SpeakerQuestionAnswerDTOMapper.cs:11), the partialMapToDTO(SpeakerQuestionAnswerDTOMapper.cs:16), and the hand-writtenMapToDTOs(SpeakerQuestionAnswerDTOMapper.cs:19-23). - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>closed over the answer entity, its DTO, andSpeakerQuestionAnswerIdentifierType(SpeakerQuestionAnswerDTOMapper.cs:13), plus Mapperly. - Concept introduced: none new; see
SpeakerCategoryItemDTOMapperfor the generated-mapper mechanism and whyMapToDTOsis 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:
SpeakerDTOMapperholds it as a[UseMapper]field (SpeakerDTOMapper.cs:26-27) and the generator calls it while fillingSpeakerDTO.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 whileSpeakerCategoryItemgets one atDependencyInjection.cs:118-119. The entity's navigation populator is registered on its own atDependencyInjection.cs:123, and the comment above it records why that pair is uneven:SpeakerQuestionAnswerhas no query service today, and registering the populator future-proofs the one that would be added alongside it (DependencyInjection.cs:121-122). Covered bySpeakerQuestionAnswerDTOMapperTests(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 theCategoryItemIdentifierTypealias. - 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 (anint),defaultis 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.AddSpeakerCategoryItematMMCA.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 byAddSpeakerCategoryItemCommandValidatorTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCommandValidatorTests.cs:6, instantiating the validator atSpeakerCommandValidatorTests.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, andSpeakerCategoryItemDTO(AddSpeakerCategoryItemHandler.cs:19);IUnitOfWork, passed straight through to the base (AddSpeakerCategoryItemHandler.cs:16);SpeakerCategoryItemDTOMapperas a concrete type (AddSpeakerCategoryItemHandler.cs:17);Result; andMicrosoft.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, inAddChildEntityHandlerBase.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 withError.NotFoundstamped with the handler name and the parent type name when it is gone (ChildEntityHandlerBase.cs:111-112), callApply, 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:Applyis 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 theSpeakerCategoryItemChangeddomain event live (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:314-338).Includesis 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 atSpeaker.cs:318runs 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 totrueon 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 concretedtoMapper, and a typedILogger, forwarding only the unit of work to the base (AddSpeakerCategoryItemHandler.cs:15-19). Then five members, and nothing else.Includesreturns[nameof(Speaker.SpeakerCategoryItems)](AddSpeakerCategoryItemHandler.cs:24).ParentIdpullscommand.SpeakerId(AddSpeakerCategoryItemHandler.cs:27).Applycalls the aggregate method and returns itsResult<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).MapChildis the one-line call into the module's own child mapper (AddSpeakerCategoryItemHandler.cs:34). The base takes aMapChildhook rather than an injectedIEntityDTOMapperbecause the DTO belongs to the CHILD entity, whose identifier type is not the parent's (ChildEntityHandlerBase.cs:20-24).LogAddedforwards to the source-generatedLogCategoryItemAdded(AddSpeakerCategoryItemHandler.cs:37-38, declared atAddSpeakerCategoryItemHandler.cs:40-41), which is why the class ispartial. 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 atMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:242) and injected intoSpeakerCategoryItemsControllerasICommandHandler<AddSpeakerCategoryItemCommand, Result<SpeakerCategoryItemDTO>>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:50). Its counterpart isRemoveSpeakerCategoryItemHandler. Covered byAddSpeakerCategoryItemHandlerTests(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-commitOnAddedAsync(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
QuestionCreateRequestinto aQuestionentity by calling the aggregate's static factory. - Depends on:
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>closed overQuestion,QuestionCreateRequest, andQuestionIdentifierType(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 raisesQuestionChangedon success (Question.cs:94), returning aResult<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 (contrastSpeakerCategoryItemDTOMapper).[Rubric §3, Clean Architecture]: keeping it in its own class is what letsCreateQuestionHandlerand 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 returnsTask.FromResult(Question.Create(...))(QuestionCreateRequestMapper.cs:19-26): the work is synchronous and theTaskexists only to satisfy the async interface. Two things in that call are worth reading twice. The nullableQuestionEntityandQuestionTypeare 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. AndquestionSourceis 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 intoCreateQuestionHandler(CreateQuestionHandler.cs:22), which forwards it to its base; the base invokes it atMMCA.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 atMMCA.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) andQuestionTextRules<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, bothIncludethe same generic rule set, parameterized by a property selector (QuestionCreateRequestValidator.cs:10).Includemerges 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>attachesNotEmptyandMaximumLength(QuestionInvariants.QuestionTextMaxLength)with stable error codesQuestion.QuestionText.RequiredandQuestion.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.QuestionTextMaxLengthforwards toQuestionDTO.QuestionTextMaxLength(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:16, value declared atMMCA.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 classwhose whole body is an expression-bodied constructor (QuestionCreateRequestValidator.cs:9-10) callingInclude(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 byValidatingCommandDecorator<TCommand, TResult>ahead ofCreateQuestionHandler. Covered byQuestionCreateRequestValidatorTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Questions/Validation/QuestionCreateRequestValidatorTests.cs). - Caveats / not-in-source: only
QuestionTextis validated here.QuestionEntity,QuestionType, andQuestionSourceare left entirely to the domain invariants insideQuestion.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
SpeakerCreateRequestinto aSpeakerentity by calling the aggregate's static factory. - Depends on:
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>closed overSpeaker,SpeakerCreateRequest, andSpeakerIdentifierType(SpeakerCreateRequestMapper.cs:11-12);Speaker;Result. - Concept reinforced, the request mapper as the only door into a factory: see
QuestionCreateRequestMapperfor the pattern.Speaker.Create(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:116-171) parses the optional email into anEmailvalue object and bails on a malformed address (Speaker.cs:130-137), combines the first- and last-name invariants withResult.Combineso both failures surface at once (Speaker.cs:139-143), assigns the client-supplied id or generates one (Speaker.cs:161), and raisesSpeakerChanged(Speaker.cs:168). The id fallback carries its own scar: the comment records that the previousid!.Valuethrew "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 returnsTask.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); onlyFullNameis dropped, because the entity computes it (Speaker.cs:61). The method is synchronous work behind an async signature:Task.FromResultsatisfies 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
CreateSpeakerHandlerasIEntityRequestMapper<Speaker, SpeakerCreateRequest, SpeakerIdentifierType>(CreateSpeakerHandler.cs:17) and forwarded to its base (CreateSpeakerHandler.cs:20-21), which invokes it atMMCA.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 atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133. - Caveats / not-in-source: the factory's
idparameter isSpeakerIdentifierType?, but the request'sIdis the non-nullable alias, so this path always passes a value and theGuid.NewGuid()fallback atSpeaker.cs:161is unreachable from it. APOST /Speakersbody that omitsIdtherefore creates a speaker whose key isGuid.Emptyrather than a fresh GUID. The null-id branch is reached only by callers that passid: nullexplicitly, 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 oneIncludeof the module's shared speaker rule set. - Depends on: FluentValidation's
AbstractValidator<T>(SpeakerCreateRequestValidator.cs:1,SpeakerCreateRequestValidator.cs:7) andSpeakerFieldRules<T>(SpeakerCreateRequestValidator.cs:2,SpeakerCreateRequestValidator.cs:10). - Concept reinforced, rule composition with
Include, one layer deeper:QuestionCreateRequestValidatorincludes a single field rule set parameterized by a selector. This validator includes a rule set that is itself nothing but sixIncludecalls (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/Validation/SpeakerValidationRules.cs:117-125), and it needs no selectors at all, becauseSpeakerFieldRules<T>is constrained toISpeakerFieldsRequest(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 isSpeakerFirstNameRules<T>andSpeakerLastNameRules<T>, both extensions of the framework'sRequiredStringRules<T>(SpeakerValidationRules.cs:12-28);SpeakerEmailRules<T>, which applies the sharedEmailRules<T>only when a value is supplied (SpeakerValidationRules.cs:41-47); and three URL rule sets that applyAbsoluteUrlRules<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 ajavascript:ordata: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 classwhose whole body is an expression-bodied constructor (SpeakerCreateRequestValidator.cs:9-10) callingInclude(new SpeakerFieldRules<SpeakerCreateRequest>()). The ceilings all come from the domain rather than from the validator:SpeakerInvariants.FirstNameMaxLengthandLastNameMaxLengthare 200,EmailMaxLengthis 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 onSpeakerDTO(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 byValidatingCommandDecorator<TCommand, TResult>ahead ofCreateSpeakerHandler. Covered bySpeakerCreateRequestValidatorTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerCreateRequestValidatorTests.cs), with the rule sets themselves covered bySpeakerValidationRulesTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/Validation/SpeakerValidationRulesTests.cs). - Caveats / not-in-source:
Bio,TagLine,ProfilePicture, andTwitterHandleare not onISpeakerFieldsRequestand are validated nowhere in this layer, even thoughSpeakerInvariantsdeclares 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
Speakeraggregate 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 overSpeaker/SpeakerDTO/SpeakerIdentifierType(SpeakerDTOMapper.cs:21); the two sibling mappersSpeakerCategoryItemDTOMapperandSpeakerQuestionAnswerDTOMapper;ICurrentUserService; theEmailvalue 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.MapToDTOdoes 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 whySpeakersControlleroverrides the export action, gates it onSpeakersManage, 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 aSpeakerCategoryItemto aSpeakerCategoryItemDTOwhile filling this type, call that mapper instead of generating a second, private copy of the same mapping. The payoff is thatSpeakerDTO'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 aGET /SpeakerCategoryItemsread 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 awithexpression on the record DTO.MapToDTOGenerated(SpeakerDTOMapper.cs:46) is theprivate partialthe 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 publicMapToDTO, so the rule applies per row on list reads too.NullableEmailToString(SpeakerDTOMapper.cs:49) is a private conversion helper Mapperly picks up to turn theEmailvalue object into the DTO'sstring?(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.
- The primary constructor takes the two child mappers and
- 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) andUpdateSpeakerHandler(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/UseCases/Update/UpdateSpeakerHandler.cs:20) for the write-path response DTO, and intoSpeakerEntityQueryService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Speakers/SpeakerEntityQueryService.cs:19), the speaker-specific query service registered atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:77, which every speaker read endpoint goes through (ADR-034). Covered bySpeakerDTOMapperTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/DTOs/SpeakerDTOMapperTests.cs). - Caveats / not-in-source: the redaction depends on
ICurrentUserServiceresolving a real caller. In a background or system context with no principal,IsInRolereturning 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 overSpeakerCreateRequest,Speaker,SpeakerIdentifierType, andSpeakerDTO(CreateSpeakerHandler.cs:20-21);IUnitOfWork(CreateSpeakerHandler.cs:16);IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, satisfied bySpeakerCreateRequestMapper(CreateSpeakerHandler.cs:17);SpeakerDTOMapperas 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].CreateEntityHandlerBaseowns the whole sequence inCreateCoreAsync(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:77-103): run the optionalPrepareAsyncstep 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 atCreateEntityHandlerBase.cs:25-28explains that only the unit of work knows which physical data source an entity resolves to), persist withAddAsyncplusSaveChangesAsync(CreateEntityHandlerBase.cs:138-139), then log, run the post-commit hook, and returnResult.Success(dtoMapper.MapToDTO(entity))(CreateEntityHandlerBase.cs:99-102). Every await in that path carriesConfigureAwait(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 closesICommandHandleroverSpeakerCreateRequestitself (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 injectedSpeakerDTOMapper, 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 ispartial.[Rubric §13, Observability and Operability].- Nothing else.
PrepareAsync,PersistAsync,HandleAsync, andOnCreatedAsyncare all left at their base defaults, which is the contrast worth holding onto againstCreateQuestionHandler: 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 fromMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected intoSpeakersControllerasICommandHandler<SpeakerCreateRequest, Result<SpeakerDTO>>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:49) and dispatched by itsPOST /Speakersoverride, which delegates to the base controller and then evicts theconference:speakersandconferenceoutput-cache tags (SpeakersController.cs:308-317). The action is gated on theSpeakersManagepermission (SpeakersController.cs:309). Covered byCreateSpeakerHandlerTests(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>workflowCreateSpeakerHandleruses, 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, andQuestionDTO(CreateQuestionHandler.cs:25-26);IUnitOfWork;IServiceScopeFactory(Microsoft.Extensions.DependencyInjection,CreateQuestionHandler.cs:21);IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>, satisfied byQuestionCreateRequestMapper;QuestionDTOMapper;QuestionInvariants;ResultandError; 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 reserves999_999_000to999_999_999for user-created questions (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:40,QuestionInvariants.cs:43), and the handler allocatesmax + 1inside it. Two details make that allocation honest. First, the range query passesignoreQueryFilters: 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 + 1computed 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 overridesHandleAsyncto wrapCreateCoreAsyncin a retry loop, and overridesPrepareAsyncto recompute the id per attempt.CreateCoreAsynctakes 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
IServiceScopeFactoryand the concreteQuestionDTOMapperto the usual dependencies (CreateQuestionHandler.cs:19-26), andMaxManualIdAttemptsis aconst intof 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 asUnitOfWork(CreateQuestionHandler.cs:45-46). Every later attempt creates anawait usingDI scope and resolves a freshIUnitOfWorkfrom 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
catchis an exception filter, not a blanket catch: it re-enters the loop only whileattempt < MaxManualIdAttemptsand only for a unique-key violation (CreateQuestionHandler.cs:54-58), logging a warning through the generatedLogManualIdCollision. 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), takemax + 1or the range start when the range is empty (CreateQuestionHandler.cs:78-80), fail with a plainError.Failurewhen the range is exhausted (CreateQuestionHandler.cs:82-83), and otherwise return the request rewritten with awithexpression (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 inCreateSpeakerHandler.IsUniqueKeyViolation(CreateQuestionHandler.cs:97-106) walks the wholeInnerExceptionchain 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 intoQuestionsControllerasICommandHandler<QuestionCreateRequest, Result<QuestionDTO>>(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Questions/QuestionsController.cs:37) and dispatched by itsPOST /Questionsoverride, which delegates to the base controller and then evicts theconference:questionsoutput-cache tag (QuestionsController.cs:95-103). The whole controller is gated on theQuestionsManagepermission except the explicitly anonymous read actions (QuestionsController.cs:34,QuestionsController.cs:46). Covered byCreateQuestionHandlerTests(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-Resultflavor of the shared load-mutate-save workflow, closed over the command,Speaker, andSpeakerIdentifierType(LinkUserToSpeakerHandler.cs:28);IUnitOfWork(LinkUserToSpeakerHandler.cs:26);SpeakerLinkedToUser;ResultandError;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 betweenSpeakerandUserand consistency has to flow through events. The load-bearing detail is the ordering:entity.AddDomainEvent(new SpeakerLinkedToUser(...))runs insideMutateAsync(LinkUserToSpeakerHandler.cs:63), and the base saves only afterMutateAsyncreturns (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 setsUser.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. TheOutboxProcessorlater routes the row to the registeredIMessageBustransport. This is also why the command carriesITransactional. - Walkthrough: a primary constructor (
LinkUserToSpeakerHandler.cs:25-28) and three overrides.EntityIdnames the aggregate to load,command.SpeakerId(LinkUserToSpeakerHandler.cs:31). The base does the load and returnsError.NotFoundstamped 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 exposedUnitOfWork(LinkUserToSpeakerHandler.cs:43), queries every speaker whoseLinkedUserIdequals the target user (LinkUserToSpeakerHandler.cs:44-47), and fails withError.Invariant(code: "Speaker.UserAlreadyLinked", ...)if any OTHER speaker already holds that link (LinkUserToSpeakerHandler.cs:48-55). Thes.Id != command.SpeakerIdtest 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, returningSpeaker.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 domainResultis returned unchanged (LinkUserToSpeakerHandler.cs:66), so a rejection reaches the API with its error codes intact.LogMutated(LinkUserToSpeakerHandler.cs:70-71) forwards to the generatedLogUserLinkedToSpeaker(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
MutateAsyncrather 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 thePUT /Speakers/{id}/linkaction (SpeakersControlleratMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:374-391), which returns204 No Contentand evicts the speaker output-cache tags on success (SpeakersController.cs:389-390). The emitted event is consumed on the Identity side to setUser.LinkedSpeakerId. This organizer-driven path is also the deliberate fallback for speakers the automatic email match inUserRegisteredHandlercannot claim. Covered byLinkUserToSpeakerHandlerTests(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
RowVersionhook (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 owningSpeakerIdand theSpeakerCategoryItemIdof the join entity to remove (RemoveSpeakerCategoryItemCommand.cs:12-14). - Depends on:
ICacheInvalidating, the only interface it implements (RemoveSpeakerCategoryItemCommand.cs:14); theSpeakerdomain type, referenced solely to build the cache prefix; and theSpeakerIdentifierTypeandSpeakerCategoryItemIdentifierTypemodule 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 theCategoryItemIdit 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 recordwhose 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 ofITransactional: the write touches a single aggregate in a singleSaveChangesAsync, so there is nothing to keep atomic across contexts (contrastUnlinkUserFromSpeakerCommand, 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 ofSpeakerCategoryItemsController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:186-194, handler injected atSpeakerCategoryItemsController.cs:51), a controller gated on theSpeakersManagepermission at the class level rather than per action (SpeakerCategoryItemsController.cs:47, ADR-020); handled byRemoveSpeakerCategoryItemHandler.
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:
ICacheInvalidatingandITransactional, both implemented atUnlinkUserFromSpeakerCommand.cs:12; theSpeakertype for the cache prefix; and theSpeakerIdentifierTypealias. - Concept reinforced, the transaction marker as a statement about a cross-context write: the XML comment says exactly why
ITransactionalis 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:UnlinkUserFromSpeakerHandlerraisesSpeakerUnlinkedFromUseron the aggregate inside the mutation step, so the event lands in the sameSaveChangesAsyncas the unlink (ADR-003). ImplementingITransactionalopts the message intoTransactionalCommandDecorator<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 recordwith a single member,CachePrefix => $"{typeof(Speaker).FullName}:"(UnlinkUserFromSpeakerCommand.cs:14-15). TheUserIdentifierTypeis 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 storedLinkedUserId. - 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}/linkaction ofSpeakersController, gated on theSpeakersManagepermission (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:393-409, handler injected atSpeakersController.cs:53, ADR-020); handled byUnlinkUserFromSpeakerHandler.
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
UserRegisteredintegration 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 overUserRegistered(UserRegisteredHandler.cs:48), which is where theIIntegrationEventHandler<in TIntegrationEvent>implementation actually lives;IServiceScopeFactory(BCL DI);IUnitOfWorkandIEntityQuerier<TEntity, TIdentifierType>, the read-side surface both private helpers take (UserRegisteredHandler.cs:130,UserRegisteredHandler.cs:167);IEventBus; theSpeakeraggregate;SpeakerLinkedToUser; theEmailvalue 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 scopedIUnitOfWorkas 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 fromScopedIntegrationEventHandlerBase<TIntegrationEvent>(UserRegisteredHandler.cs:48), which owns the preamble:HandleAsyncnull-guards the event, callsscopeFactory.CreateAsyncScope(), and hands the subclass that scope'sIServiceProviderinside anawait using(MMCA.Common/Source/Core/MMCA.Common.Application/DomainEvents/ScopedIntegrationEventHandlerBase.cs:45-55). The subclass therefore writes onlyHandleScopedAsync(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).LogAndRethrowlogs and returnsfalse(ScopedIntegrationEventHandlerBase.cs:99-104), so the filter never matches, the exception keeps propagating with its original stack trace, and thethrowinside the block is unreachable by construction (ScopedIntegrationEventHandlerBase.cs:59-61). A host-shutdownOperationCanceledExceptionshort-circuits the filter and passes through without an error line. This handler supplies only the message: it overridesLogHandlerFailureto write the registering user's own id through a source-generated partial (UserRegisteredHandler.cs:126-127, declared atUserRegisteredHandler.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 afterOutbox:MaxRetriesattempts, 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 theEmailvalue object, bails with a warning if it is malformed (UserRegisteredHandler.cs:136-141), and queries speakers whose recordedEmailequals it (UserRegisteredHandler.cs:144-148). A unique-name fallback used to run when the email missed, covering Sessionize-imported speakers whoseEmailis always null because the publicview/Allendpoint 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-deliveryIServiceProvider, resolvesIUnitOfWorkandIEventBusfrom it, and asks the unit of work for theSpeakerrepository (UserRegisteredHandler.cs:56-59).- On an email miss it calls
LogNameMatchCandidatesAsyncand 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 throughLinkUserToSpeakerHandler(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-publishesSpeakerLinkedToUserso Identity can re-sync, then returns without re-linking (UserRegisteredHandler.cs:78-84); and a rejectedspeaker.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), publishesSpeakerLinkedToUserthrough theIEventBus(UserRegisteredHandler.cs:100-102), and logs (UserRegisteredHandler.cs:104). Identity consumes that event to setUser.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 arbitraryFirstOrDefaultcould 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 keepsIsDeletedrows 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 thespeaker_idclaim; it appears on the next token refresh after this handler completes. The class comment also records why the handler does not evict the 5-minuteSpeakersCacheoutput 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 atMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:194-198) and fed by the Conference service host, which wires the genericIntegrationEventConsumer<T>adapter for this event withx.RegisterIntegrationEventConsumer<UserRegistered>()(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:352, explained atProgram.cs:338-342). Its producer is Identity's registration path, which raisesUserRegisteredon the user aggregate. Covered byUserRegisteredHandlerTests(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 atUserRegisteredHandler.cs:93-96(two registrations matching one speaker concurrently) is expected to surface as aDbUpdateException; that the database actually enforces a unique index onSpeaker.LinkedUserIdis 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
SpeakerCreateRequestit doubles as the command handled byCreateActivityHandler. - Depends on:
ICreateRequest,ICacheInvalidating, andIActivityFieldsRequest, all three declared atActivityCreateRequest.cs:11; theActivitytype for the cache prefix; theActivityIdentifierTypeandEventIdentifierTypemodule aliases. - Concept reinforced, an id the caller cannot choose: the contrast with
SpeakerCreateRequestis the lesson. A speaker id is client-assigned because Sessionize owns it; an activity is planned inside the app, soActivitycarries[IdValueGenerated](MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:19) and its factory assignsdefaultrather than the supplied value (Activity.cs:120-125). The request still exposes anIdmember, 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 atMMCA.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 omitsEventId(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 classwithinit-only members.CachePrefix => $"{typeof(Activity).FullName}:"(ActivityCreateRequest.cs:13-14) is the invalidation tag.Id(ActivityCreateRequest.cs:17) is ignored as described above. OnlyNameisrequired(ActivityCreateRequest.cs:20).Descriptionis optional (ActivityCreateRequest.cs:23);StartTimeandEndTime(ActivityCreateRequest.cs:26,ActivityCreateRequest.cs:29) are plainDateTimevalues in the owning event's wall-clock time. The venue trioVenueName,VenueAddress, andVenueUrl(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 emptyVenueNamemeans the main conference venue.SortOrder(ActivityCreateRequest.cs:41) breaks ties between activities that start at the same minute, andEventId(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
ActivitiesControlleron theActivitiesManage-gatedPOST /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 byActivityCreateRequestValidator; mapped byActivityCreateRequestMapper; handled byCreateActivityHandler. The update side has its own request,ActivityUpdateRequest, applied byActivityUpdateApplier.
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
Activityentity toActivityDTO. It is the simplest mapper in the Conference module: no nested collections, no redaction, no injected services. - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>closed overActivity,ActivityDTO, andActivityIdentifierType(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 injectsICurrentUserServiceand 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 asealed partialclass (ActivityDTOMapper.cs:12-14) lets Mapperly emit the property-by-property body of thepartial 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:MapToDTOsnull-guards and projects with a collection expression,[.. entityCollection.Select(MapToDTO)](ActivityDTOMapper.cs:20-24).ActivityDTOcarriesRowVersionthroughIConcurrencyAware(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 asIEntityDTOMapper<...>by theEntityQueryService<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 throughAddEntityCrud<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 byActivityDTOMapperTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Activities/DTOs/ActivityDTOMapperTests.cs). - Caveats / not-in-source: Activity has no
IEntityDTOProjectorsibling (theActivities/DTOs/folder holds only this mapper, and the projector scan atMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:209-213is 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 overCategoryandConferenceCategoryIdentifierType(DeleteConferenceCategoryHandler.cs:14-15);IUnitOfWork, passed straight through to the base (DeleteConferenceCategoryHandler.cs:14); theCategoryItemchild 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 ownDelete()cascade has to see, declared inIncludes, and a cross-aggregate invariant that must refuse the delete first, implemented inOnDeletingAsync. A subclass that overrides neither behaves exactly like the base, down to the query it issues. This handler overrides onlyIncludes, so it keeps the base'sOnDeletingAsyncacceptance (DeleteEntityHandler.cs:126-130). The load-bearing consequence is in the base'sLoadAsync(DeleteEntityHandler.cs:101-114): with no includes it issues the bare by-id query, and with includes it switches to the eager-loading overload underAsTracking(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 combinesDeleteChildren<CategoryItem, CategoryItemIdentifierType>(_categoryItems)withbase.Delete()in oneResult.Combine(Category.cs:106-108), so a failing child aggregates into the combined result instead of leaving a half-applied delete, and raisesCategoryChangedwithDomainEntityState.Deletedonly 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
IUnitOfWorkto 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 nameDeleteEntityHandler(DeleteEntityHandler.cs:50). The value is stamped into theSourceof theNotFoundfailure (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 atCategory.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), failNotFoundwhen the row is gone (DeleteEntityHandler.cs:73-74), run the pre-delete refusal hook (DeleteEntityHandler.cs:76-78), callentity.Delete()(DeleteEntityHandler.cs:80), and save plus log only on success (DeleteEntityHandler.cs:81-86).
- The whole class is a primary constructor forwarding
- Why it's built this way: the same shape recurs for every aggregate whose delete cascades, which is why
DeleteEventHandlerandDeleteSessionHandlerare 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 theDELETE /ConferenceCategories/{id}override ofConferenceCategoriesController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Categories/ConferenceCategoriesController.cs:137-146, handler injected atConferenceCategoriesController.cs:40), a controller gated onCategoriesManageat the class level (ConferenceCategoriesController.cs:35) that evicts theconference:categoriesoutput-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 usesTryAddScoped(DependencyInjection.cs:81) so ordering decides. There is no dedicated unit-test file for this handler inMMCA.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
ActivityCreateRequestinto anActivityentity by calling the aggregate's static factory. Structurally identical toSpeakerCreateRequestMapper. - Depends on:
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>closed overActivity,ActivityCreateRequest, andActivityIdentifierType(ActivityCreateRequestMapper.cs:11-12);Activity;Result. - Concept reinforced, the factory decides, the mapper only forwards:
CreateEntityAsyncnever constructs an entity itself.Activity.Create(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:99-130) combines five invariants in oneResult.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 raisesActivityChangedwithDomainEntityState.Added(Activity.cs:127).[Rubric §4, Domain-Driven Design]: anActivitythat exists is anActivitythat passed its invariants. - Walkthrough:
CreateEntityAsync(ActivityCreateRequestMapper.cs:15) null-guards the request (ActivityCreateRequestMapper.cs:17) and returnsTask.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, andTask.FromResultsatisfies 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
CreateActivityHandlerby 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
Idis forwarded (ActivityCreateRequestMapper.cs:20) but discarded downstream, becauseActivityis[IdValueGenerated](Activity.cs:19) and the factory assignsdefaultin that case (Activity.cs:120-125). If that attribute were ever removed, the same line would evaluateid!.Valueon 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>andActivityEventIdRules<T>fromMMCA.ADC.Conference.Application.Activities.Validation(ActivityCreateRequestValidator.cs:2). - Concept reinforced, two layers of rule composition: the outer layer is this class, two
Includecalls. The inner layer isActivityFieldRules<T>, itself a validator constrained towhere T : IActivityFieldsRequestthat 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 atActivityValidationRules.cs:13-18,OptionalStringRules<T>for description, venue name, and venue address atActivityValidationRules.cs:25-30,ActivityValidationRules.cs:37-42,ActivityValidationRules.cs:49-54), each supplying a display label and a max length that comes fromActivityInvariants, which in turn re-declares the constants owned byActivityDTO(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/ActivityInvariants.cs:16-28, constants atMMCA.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>wrapsAbsoluteUrlRules<T>in aWhenso an empty value passes but a non-empty one must be absolute http or https, which is what stops ajavascript:link reaching the public activity page (ActivityValidationRules.cs:63-73), andActivityTimeRangeRules<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 explicitWithErrorCode, so a client can branch onActivity.EndTime.BeforeStartinstead 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 offIActivityFieldsRequest.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 aRequiredIdRules<T, TId>with the error codeActivity.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 (
ActivityUpdateRequestValidatoris the other half of the pair).[Rubric §15, Best Practices & Code Quality]. - Where it's used: discovered by
AddValidatorsFromAssemblyinside the module scan (MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:252) and executed byValidatingCommandDecorator<TCommand, TResult>beforeCreateActivityHandlerever runs (ADR-014). Covered byActivityCreateRequestValidatorTests(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>, andActivityEventIdRules<T>have no counterpart inActivity.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 anActivitywithout going through this validator (a seeder, a future importer) can therefore produce a negativeSortOrder, an over-long description, or an activity with an emptyEventId. 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 overActivityCreateRequest,Activity,ActivityIdentifierType, andActivityDTO(CreateActivityHandler.cs:20-21);IUnitOfWork,IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>(satisfied byActivityCreateRequestMapper), andActivityDTOMapperas 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 implementsICommandHandler<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 inCreateCoreAsync(CreateEntityHandlerBase.cs:77-103): optionalPrepareAsync(CreateEntityHandlerBase.cs:84, default pass-through atCreateEntityHandlerBase.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, defaultAddAsyncplusSaveChangesAsyncatCreateEntityHandlerBase.cs:138-139), theLogCreatedhook (CreateEntityHandlerBase.cs:99), theOnCreatedAsyncpost-commit hook (CreateEntityHandlerBase.cs:100), and finally the DTO projection (CreateEntityHandlerBase.cs:102). Resolving the repository throughIUnitOfWorkrather than constructor-injectingIRepository<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, andILogger<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 partialLogActivityCreated(CreateActivityHandler.cs:26-27). The base's default is an explicit no-op, because logging vocabulary is per module (CreateEntityHandlerBase.cs:148-151). Readingentity.Idhere 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 toCreateCoreAsyncagainst the injected unit of work.
- Primary constructor (
- Why it's built this way: the pipeline supplies everything this handler does not:
ValidatingCommandDecorator<TCommand, TResult>runsActivityCreateRequestValidatorfirst, andCachingCommandDecorator<TCommand, TResult>acts on theCachePrefixthe request declares (ADR-014, chain atMMCA.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 fromMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:133); injected intoActivitiesControllerasICommandHandler<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), whosePOST /Activitiesthe controller overrides only to evict theconference:activitiesandconferenceoutput-cache tags after the create (ActivitiesController.cs:162-171). The action is gated on theActivitiesManagepermission (ActivitiesController.cs:163) while the read endpoints stay anonymous (ActivitiesController.cs:82,ActivitiesController.cs:98). Covered byCreateActivityHandlerTests(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, andSpeakerIdentifierType(UnlinkUserFromSpeakerHandler.cs:28);IUnitOfWork, forwarded to the base (UnlinkUserFromSpeakerHandler.cs:26); theSpeakeraggregate;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 bareResultfor publish/close/remove-style commands; it inherits the machinery fromMutateEntityHandlerCore<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), failNotFoundstamped withHandlerNameand the entity type (MutateEntityHandlerBase.cs:282-283), stamp the caller'sRowVersionas the original when the command reports one (MutateEntityHandlerBase.cs:291-292, ADR-035), runMutateAsyncand 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). RaisingSpeakerUnlinkedFromUseron the aggregate insideMutateAsync(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 clearsUser.LinkedSpeakerId. TheOutboxProcessorthen routes the row to the registeredIMessageBustransport (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 returnscommand.SpeakerId, which is all the workflow needs to load the aggregate.Includesis 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 capturespreviousUserIdBEFORE callingentity.UnlinkUser()(UnlinkUserFromSpeakerHandler.cs:42-43). This is the load-bearing line: the domain method clearsLinkedUserId(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 withSpeaker.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 domainResultunchanged (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 atUnlinkUserFromSpeakerHandler.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
ITransactionalmarker 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 byDELETE /Speakers/{id}/linkonSpeakersController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:393-409, handler injected atSpeakersController.cs:53), which evicts the speaker output-cache tags and answers204 No Content(SpeakersController.cs:407-408). The emitted event is consumed on the Identity side to clearUser.LinkedSpeakerId. Covered byUnlinkUserFromSpeakerHandlerTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Speakers/UseCases/UnlinkUserFromSpeakerHandlerTests.cs). - Caveats / not-in-source: the
previousUserId.HasValuetest (UnlinkUserFromSpeakerHandler.cs:44) can never be false on the success path, becauseUnlinkUser()fails when nothing is linked (Speaker.cs:292-299). It is defensive, not a live branch. The handler also does not overrideRowVersion(MutateEntityHandlerBase.cs:91), so the unlink is unconditional: it carries noIf-Matchprecondition, 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, andSpeakerIdentifierType(RemoveSpeakerCategoryItemHandler.cs:16);IUnitOfWork, forwarded to the base (RemoveSpeakerCategoryItemHandler.cs:14); theSpeakeraggregate and itsSpeakerCategoryItemchild;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 toMutateEntityHandlerBase<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 wrongNotFound. 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
SpeakerCategoryItemscollection andMutateAsynchands the id toentity.RemoveSpeakerCategoryItem(...)(RemoveSpeakerCategoryItemHandler.cs:29), which resolves the child through the sharedRemoveChildOrNotFoundhelper, soft-deletes it, and raisesSpeakerCategoryItemChanged(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):IUnitOfWorkstraight to the base, plus a typedILogger<RemoveSpeakerCategoryItemHandler>the class keeps for its own log line. No DTO mapper, because a remove returns the non-genericResult. Includes => [nameof(Speaker.SpeakerCategoryItems)](RemoveSpeakerCategoryItemHandler.cs:19) is the mandatory override, naming the collection exposed atSpeaker.cs:67. It reaches the load through the core's defaultLoadAsync(MutateEntityHandlerBase.cs:152-160), which passes it plusAsTrackingto the repository's eager-loading by-id overload.EntityId(RemoveSpeakerCategoryItemHandler.cs:22) returnscommand.SpeakerId: the command's other id, theSpeakerCategoryItemId, 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]partialLogCategoryItemRemoved(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].
- Primary constructor (
- 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 atMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:240-244); invoked through the decorator pipeline bySpeakerCategoryItemsController'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 byRemoveSpeakerCategoryItemHandlerTests(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 optionalSessionCategoryItemIdfor the join entity, and theCategoryItemIdbeing associated (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemCommand.cs:10-13). - Depends on:
ICacheInvalidating, the one interface it implements (AddSessionCategoryItemCommand.cs:13); theSessiontype, used only for itsFullNamewhen building the cache prefix (AddSessionCategoryItemCommand.cs:1,:16); and theSessionIdentifierType,SessionCategoryItemIdentifierType, andCategoryItemIdentifierTypemodule aliases, all threeint(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, ornullfor database-generated identity" (AddSessionCategoryItemCommand.cs:8). The REST path always passesnulland 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 prefixAddSessionQuestionAnswerCommandandRemoveSessionCategoryItemCommanddeclare, 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 /SessionCategoryItemsaction ofSessionCategoryItemsControllerfrom anAddSessionCategoryItemRequestbody (SessionCategoryItemsController.cs:165-171), on a controller gated by theSessionsManagepermission (SessionCategoryItemsController.cs:47, ADR-020); validated byAddSessionCategoryItemCommandValidator; handled byAddSessionCategoryItemHandler.
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 overSessionandSessionIdentifierType(PublicSessionStatusSpecification.cs:20);SessionStatusesfor theAcceptedconstant (:24); andSystem.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 instanceCriteriaoverride 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 hasSessionStatuses.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 theAcceptedconstant 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) iss => s.Status == null || s.Status == SessionStatuses.Accepted; the null branch is not an oversight but the organizer-created case.Criteria(:27) is theoverridethe 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 formSessionBookmarkValidationServiceandSessionQuestionAnswerRulescall on an already-loaded row. - Where it's used:
PublicConferenceVisibilityuses both forms, the static expression as thelocalPredicateof 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);GetPublicSessionFilterHandleruses 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.IsEligiblecould 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); theSessionaggregate and itsSessionInvariantshelpers;ResultandError. - 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), returnsError.NotFoundstamped with this service as source andSessionas target when it is missing (:26-30), then runs two domain invariants in order:SessionInvariants.EnsureNotServiceSessionfor BR-91, since a break or a lunch slot is not something an attendee bookmarks (:33, invariant atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:94), andSessionInvariants.EnsureStatusIsEligiblefor BR-49 (:38, invariant atSessionInvariants.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 withPublicSessionStatusSpecification: 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 awhere: s => s.EventId == eventIdpredicate and projected into aResult<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'sCreateBookmarkHandler(CreateBookmarkHandler.cs:18) andGetUserBookmarksHandler; exposed over the wire bySessionBookmarksGrpcService, which wraps this instance as itsinner(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Grpc/SessionBookmarksGrpcService.cs:23). Covered bySessionBookmarkValidationServiceTests(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:
GetSessionIdsByEventAsyncloads 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
SessionCategoryItemjoin entity, source-generated by Mapperly. - Depends on:
IEntityDTOMapper<TEntity, TEntityDTO, TIdentifierType>closed overSessionCategoryItem/SessionCategoryItemDTO/SessionCategoryItemIdentifierType(MMCA.ADC.Conference.Application/Sessions/DTOs/SessionCategoryItemDTOMapper.cs:13), andRiok.Mapperly.Abstractionsfor 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 apartialclass makes the generator emit the body ofMapToDTOas 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) ispartialwith no body, which is the generator's hook.MapToDTOs(:19-23) is hand-written:ArgumentNullException.ThrowIfNullthen a collection-expression spread overSelect(MapToDTO). Declaring the plural on the class is what makes it reachable through the concrete type, which matters becauseAddSessionCategoryItemHandlerinjects the concrete mapper, not the interface. - Where it's used: injected into
AddSessionCategoryItemHandler(MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:18); composed intoSessionDTOMapperas a[UseMapper]field (SessionDTOMapper.cs:26-27); resolved by the genericEntityQueryService<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 bySessionCategoryItemDTOMapperTests(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 overSessionQuestionAnswer/SessionQuestionAnswerDTO/SessionQuestionAnswerIdentifierType(MMCA.ADC.Conference.Application/Sessions/DTOs/SessionQuestionAnswerDTOMapper.cs:13), andRiok.Mapperly.Abstractions(SessionQuestionAnswerDTOMapper.cs:11). - Concept reinforced: none new. Identical in structure to
SessionCategoryItemDTOMapper: apartialMapToDTOthe generator fills in (SessionQuestionAnswerDTOMapper.cs:16) and a hand-written null-guardedMapToDTOs(: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 intoBatchAddSessionQuestionAnswersHandler, 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 intoSessionDTOMapper(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 bySessionQuestionAnswerDTOMapperTests(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, andQuestionInvariantsfrom the Domain layer (:1-3); theSession,Event, andQuestionaggregates as parameter types;ResultandError(: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 letsBatchAddSessionQuestionAnswersHandlerread 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 staticmethods, both returning a bareResultand both taking asourcestring 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.EnsureNotServiceSessionfor BR-91 (:27-31, defined atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:94): a break or a lunch slot takes no feedback.SessionInvariants.EnsureStatusIsEligiblefor BR-49 (:34-38, defined atSessionInvariants.cs:109), the same allow-listPublicSessionStatusSpecificationexpresses for reads, here in its compiled-code form against a loaded row. Then BR-108: anullparentEventbecomesError.NotFoundtargeted atEvent(:41-44), and only a non-null event reachesEventInvariants.EnsureEventIsPublished(:46, defined atMMCA.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), returningError.ValidationcodedQuestion.NotFoundOrWrongEntityand targeted atnameof(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 toQuestionInvariants.EnsureAnswerValueMatchesQuestionType(question.QuestionType, answerValue, source)(:69-70, defined atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:118), so the knowledge of what aRatingor aTextanswer 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:
AddSessionQuestionAnswerHandlercalls 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);BatchAddSessionQuestionAnswersHandlercallsEnsureSessionAcceptsFeedbackonce for the request (BatchAddSessionQuestionAnswersHandler.cs:83-84) andEnsureAnswerIsValidonce per answer against a pre-builtquestionsByIddictionary (:98-103). Because the class isinternal 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:59is 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 overSessionSpeaker/SessionSpeakerDTO/SessionSpeakerIdentifierType(MMCA.ADC.Conference.Application/Sessions/DTOs/SessionSpeakerDTOMapper.cs:13), andRiok.Mapperly.Abstractions(SessionSpeakerDTOMapper.cs:11). - Concept reinforced: none new; see
SessionCategoryItemDTOMapper. Same two members, same split between the generatedMapToDTO(SessionSpeakerDTOMapper.cs:16) and the hand-writtenMapToDTOs(: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 intoSessionDTOMapper(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 bySessionSpeakerDTOMapperTests(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
SessionDTOarrives carrying itsSessionSpeakersat all depends on whether the read path ranSessionNavigationPopulatorfor 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: theCategoryItemIdmust 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
AddSessionCategoryItemHandlersees 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 inSession.AddSessionCategoryItemas theSession.CategoryItem.Duplicateinvariant (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). BecauseCategoryItemIdentifierTypeisint(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 asdefault(CategoryItemIdentifierType)rather than0means the rule survives an alias change to a GUID without editing. - Why it's built this way: the convention scan auto-registers every
AbstractValidatorin 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 byAddSessionCategoryItemCommandValidatorTests(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:
SessionIdis 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 sharedAddChildEntityHandlerBase<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 theICommandHandlerimplementation;IUnitOfWork, taken by primary constructor and forwarded to the base (:17,:20);SessionCategoryItemDTOMapper, injected as the concrete type (:18);Result; andMicrosoft.Extensions.Loggingfor 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 byParentId(command)withIncludesandAsTracking(:107-109), fail with a stampedNotFoundwhen it is gone (:110-111), callApplyand short-circuit on the refused invariant (:113-115),SaveChangesAsync(:117), thenLogAdded,OnAddedAsync, andMapChild(:121-124). What makes this more than a code-dedup exercise is thatIncludesis 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.AddSessionCategoryItemguards 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 concretedtoMapper, and a typedILogger, and forwards onlyunitOfWorkto 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.AsTrackingis not overridden, so the base default oftrueapplies (ChildEntityHandlerBase.cs:50); an untracked graph would make the save a silent no-op.ParentIdreturnscommand.SessionId(:28), which is the only place the command's shape meets the base's generic key.Applyis the single line that does business work:parent.AddSessionCategoryItem(command.SessionCategoryItemId, command.CategoryItemId)(:31-32). The aggregate rejects a duplicate withSession.CategoryItem.Duplicate(Session.cs:401-408), creates the child through its own factory (Session.cs:410), and raisesSessionCategoryItemChanged(Session.cs:418).MapChildisdtoMapper.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.LogAddedforwards to the source-generatedLogCategoryItemAddedToSession(: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 answer201 Createdwith a location header (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionCategoryItemsController.cs:179-182).
- The primary constructor takes
- Why it's built this way: atomicity is the one
SaveChangesAsyncinside the base covering both the join row and the domain event the aggregate raised, both in the sameADC_Conferencedatabase (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 sameNotFoundstamping, 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 intoSessionCategoryItemsControllerasICommandHandler<AddSessionCategoryItemCommand, Result<SessionCategoryItemDTO>>(SessionCategoryItemsController.cs:50) and dispatched by itsPOSTaction, which is marked[Idempotent]so a retried request with the sameIdempotency-Keyreplays the first response instead of adding a second row (SessionCategoryItemsController.cs:163-171), and which evicts theconference:sessions,conference:categories, andconferenceoutput-cache tags before returning (:178). Covered byAddSessionCategoryItemHandlerTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandlerTests.cs:12). - Caveats / not-in-source:
HandlerNameis not overridden, so theNotFoundfailure is stamped withGetType().Name(ChildEntityHandlerBase.cs:44), which resolves to the concrete handler's name at runtime. TheOnAddedAsyncpost-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
SessionCategoryItemjoin entity when it is read as its own entity rather than as a child of a session. It hydrates one navigation: the parentSessionback-reference (MMCA.ADC.Conference.Application/Sessions/SessionCategoryItemNavigationPopulator.cs:7-9). - Depends on:
DeclarativeNavigationPopulator<TEntity>closed overSessionCategoryItem(SessionCategoryItemNavigationPopulator.cs:13);FKNavigationDescriptor<TEntity, TChild, TChildId>(:15);IUnitOfWork, passed straight through to the base (:11-13); and theSessionaggregate 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
SessionNavigationPopulatoralso uses. AFKNavigationDescriptorreads 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 oneWHERE 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). TheAssignActionhere therefore ends inFirstOrDefault()(SessionCategoryItemNavigationPopulator.cs:20), because a reference navigation wants one row out of a list. The descriptor also declaresRequiresChildren => 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 theincludeFKs/includeChildrenarguments 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 withPropertyName = nameof(SessionCategoryItem.Session)(:17),ParentKeySelector = e => e.SessionId(:18),ChildForeignKeySelector = child => child.Id(:19), andAssignAction = (e, sessions) => e.SetSession(sessions.FirstOrDefault())(:20). Two details are load-bearing.PropertyNameis not decoration: the base loads a descriptor only when that exact property name appears in the query'sUnsupportedIncludesmetadata (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 ownSetSessionmutator (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 baseEntityQueryService<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 bySessionCategoryItemNavigationPopulatorTests(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
Sessionaggregate into aSessionDTO, 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 overSession/SessionDTO/SessionIdentifierType(MMCA.ADC.Conference.Application/Sessions/DTOs/SessionDTOMapper.cs:18);SessionSpeakerDTOMapper,SessionQuestionAnswerDTOMapper, andSessionCategoryItemDTOMapper, 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 aSessionSpeakerwhile generating the body ofMapToDTO(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 aSessionSpeakerprojects 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), thepartialMapToDTOthe generator fills in (:30), and the hand-writtenMapToDTOswith its null guard andSelectspread (:33-37). The class issealed partialand 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
SessionDTOthat 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 itsCreateEntityHandlerBase(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/Create/CreateSessionHandler.cs:26,:29-30), and intoUpdateSessionHandler, 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 asIEntityDTOMapper<Session, SessionDTO, SessionIdentifierType>by the genericEntityQueryService<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 bySessionDTOMapperTests(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
EventandRoomreferences are populated at map time depends entirely on whether the read path ranSessionNavigationPopulatorfor 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
Sessionaggregate, and the richest one in the Conference module. It declares five navigations that EF Core cannot materialize through.Include()on this model: FK references toEventandRoom, and the three child collectionsSessionSpeakers,SessionQuestionAnswers, andSessionCategoryItems(MMCA.ADC.Conference.Application/Sessions/SessionNavigationPopulator.cs:8-11). Notably, its class body is empty. - Depends on:
DeclarativeNavigationPopulator<TEntity>closed overSession(SessionNavigationPopulator.cs:15); both descriptor kinds,FKNavigationDescriptor<TEntity, TChild, TChildId>(:17,:24) andChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>(:31,:38,:45);IUnitOfWork, passed straight through to the base (:13-15); and theEvent,Room,SessionSpeaker,SessionQuestionAnswer, andSessionCategoryItemtypes. - 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
FKNavigationDescriptorwalks forward along a foreign key the parent holds and assigns one row (AssignActionends inFirstOrDefault(),:22,:29); aChildNavigationDescriptorwalks backward from a foreign key the children hold and assigns the whole list (:36,:43,:50). The base treats them differently at load time:RequiresChildrenisfalseon the FK kind (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/FKNavigationDescriptor.cs:23) andtrueon the child kind (MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25), and it tests that flag against the caller'sincludeFKsandincludeChildrenarguments 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 overRoomId, which is nullable on the session because a session need not be scheduled into a room yet;LoadFKPropertyAsyncdrops null keys before it builds theINclause 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), andSessionCategoryItems(:45-51) each invert the direction:ParentKeySelector = e => e.Id,ChildForeignKeySelector = child => child.SessionId, and anAssignActionthat calls the aggregate's ownSetSessionSpeakers/SetSessionQuestionAnswers/SetSessionCategoryItemsmutator (:36,:43,:50, declared atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:386,:546,:472).- Every
AssignActiongoes through an aggregate member rather than a back-door property write, including the two FK references, which use the publicSetEventandSetRoom(Session.cs:301,:305). The three collection mutators areinternal, reachable only because the Domain project grantsInternalsVisibleToto 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 aSessionis read with any of these five navigations requested (ADR-034). Its output is whatSessionDTOMapperprojects. Covered bySessionNavigationPopulatorTests(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.
AddSessionCategoryItemHandlerdeclares anIncludesoverride the shared base passes to the repository (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionCategoryItem/AddSessionCategoryItemHandler.cs:25), andAddSessionQuestionAnswerHandlerpasses 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
SessionQuestionAnswerread as its own entity. One navigation: the parentSessionback-reference (MMCA.ADC.Conference.Application/Sessions/SessionQuestionAnswerNavigationPopulator.cs:7-9). - Depends on:
DeclarativeNavigationPopulator<TEntity>closed overSessionQuestionAnswer(SessionQuestionAnswerNavigationPopulator.cs:13);FKNavigationDescriptor<TEntity, TChild, TChildId>(:15);IUnitOfWork(:11-12); theSessionaggregate 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 atMMCA.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 baseEntityQueryService<TEntity, TEntityDTO, TIdentifierType>for the same entity (:113). Covered bySessionQuestionAnswerNavigationPopulatorTests(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
SessionSpeakerread as its own entity. One navigation: the parentSessionback-reference (MMCA.ADC.Conference.Application/Sessions/SessionSpeakerNavigationPopulator.cs:7-9). - Depends on:
DeclarativeNavigationPopulator<TEntity>closed overSessionSpeaker(SessionSpeakerNavigationPopulator.cs:13);FKNavigationDescriptor<TEntity, TChild, TChildId>(:15);IUnitOfWork(:11-12); theSessionaggregate as the FK target. - Concept reinforced: none new; see
SessionCategoryItemNavigationPopulator. Same single descriptor overPropertyName = nameof(SessionSpeaker.Session)(:17),ParentKeySelector = e => e.SessionId(:18),ChildForeignKeySelector = child => child.Id(:19),AssignActionending inSetSession(sessions.FirstOrDefault())(:20, mutator atMMCA.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 theSpeakerside of the join, so a directly readSessionSpeakergets 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 baseEntityQueryService<TEntity, TEntityDTO, TIdentifierType>for the same entity (:107). Covered bySessionSpeakerNavigationPopulatorTests, which pins the type toINavigationPopulator<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
Speakernavigation is left out of this descriptor list whileSessionis 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 optionalSessionQuestionAnswerIdfor the answer row, theQuestionIdbeing answered, and theAnswerValuetext (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionQuestionAnswer/AddSessionQuestionAnswerCommand.cs:11-15). - Depends on:
ICacheInvalidating(AddSessionQuestionAnswerCommand.cs:15), theSessiontype referenced only for itsFullNamewhen building the cache prefix, and theSessionIdentifierType/SessionQuestionAnswerIdentifierType/QuestionIdentifierTypemodule aliases (ADR-048). - Concept reinforced: none new. The nullable child id works exactly as on
AddSessionCategoryItemCommand; the file states the same contract atAddSessionQuestionAnswerCommand.cs:8, "Explicit ID for the answer entity, ornullfor database-generated identity", and the REST path always passesnull(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 fromICurrentUserServiceinsideAddSessionQuestionAnswerHandler(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 byAddSessionQuestionAnswerHandler, and built from anAddSessionQuestionAnswerRequestby the[Idempotent]POST /SessionQuestionAnswersaction ofSessionQuestionAnswersController(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 optionalSessionSpeakerIdfor the join row, and theSpeakerIdbeing associated (MMCA.ADC.Conference.Application/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerCommand.cs:10-13). - Depends on:
ICacheInvalidating(AddSessionSpeakerCommand.cs:13); theSessiontype, referenced only for itsFullNamewhen building the cache prefix; and theSessionIdentifierType/SessionSpeakerIdentifierType/SpeakerIdentifierTypemodule 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, ornullfor database-generated identity" (AddSessionSpeakerCommand.cs:8). The REST path always passesnulland 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 onAddSessionCategoryItemCommand. - 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 /SessionSpeakersaction ofSessionSpeakersControllerfrom anAddSessionSpeakerRequestbody (SessionSpeakersController.cs:163-171), on a controller gated by theSessionsManagepermission (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 byAddSessionSpeakerCommandValidator; handled byAddSessionSpeakerHandler.
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 noHandleAsyncof its own. - Depends on:
DeleteEntityHandler<TEntity, TIdentifierType>closed overSessionandSessionIdentifierType(DeleteSessionHandler.cs:19);IUnitOfWork, passed straight through to the base (:17,:19);DeleteEntityCommand<TEntity, TIdentifierType>as the message shape (:29);Microsoft.Extensions.Loggingfor 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 overridesIncludesandLogDeletedand nothing else, so the load-guard-delete-save sequence atDeleteEntityHandler.cs:67-89is the code that actually runs.[Rubric §1, SOLID]assesses substitutability at the abstraction: because the DI registration is keyed by the closedICommandHandler<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 extensibleDeleteEntityHandleras 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 threeDeleteChildren<...>calls over_sessionSpeakers,_sessionQuestionAnswers, and_sessionCategoryItemswith 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) returnsnameof(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 aNotFoundfailure keeps reportingDeleteSessionHandleras itsSourceexactly as it always has.Includes(:25-26) returns a collection expression naming all three owned child collections throughnameof. Declaring them is what switches the base'sLoadAsyncfrom the bare by-id query to the eager-loading overload underAsTracking(DeleteEntityHandler.cs:109-113). Tracking istrueby default (DeleteEntityHandler.cs:64), which matters because the cascade mutates the loaded children andSaveChangesAsyncmust 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 typedSessionIdentifierTypeparameter, 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
Eventdelete path has the same shape, which is whyDependencyInjection.cs:70registersDeleteEventHandlerfor the same reason, while aggregates with no owned cascade keep the raw generic (DependencyInjection.cs:78forSpeaker,:84forQuestion). - Where it's used: registered at
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/DependencyInjection.cs:74; invoked through thedeleteHandlerconstructor parameter ofSessionsController(SessionsController.cs:49), which passes it intoAggregateRootEntityControllerBase(SessionsController.cs:57-58). Covered byDeleteSessionHandlerTests(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,nullasks 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); theSessiontype for the key prefix;System.Globalizationfor the invariant id formatting (GetNowNextQuery.cs:1). - Concept introduced, a query that declares its own cache key: commands implement
ICacheInvalidatingand name a prefix to evict; queries implementIQueryCacheableand 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}wherescopeis the event id formatted withCultureInfo.InvariantCulture, or the literal"current"when the id is null (:30-33). Two things matter here. The key sits under the sameSessionaggregate prefix the session commands declare as theirCachePrefix, so any session write evicts this entry through prefix eviction once anIConnectionMultiplexeris 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) isTimeSpan.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 withEventId: nullfor 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 taggedconferenceandconference:sessions(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:232). Handled byGetNowNextHandler. Covered byGetNowNextQueryCacheTests(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
SessionIdand theSessionCategoryItemIdjoin row to remove (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionCategoryItem/RemoveSessionCategoryItemCommand.cs:9-11). - Depends on:
ICacheInvalidating(RemoveSessionCategoryItemCommand.cs:11), theSessiontype for the prefix, and theSessionIdentifierType/SessionCategoryItemIdentifierTypealiases. - Concept reinforced: none new. Note the asymmetry with the matching Add command:
AddSessionCategoryItemCommandtakes 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 ofSessionCategoryItemsController, 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 theSessionsManagepermission (SessionCategoryItemsController.cs:47); handled byRemoveSessionCategoryItemHandler. - Caveats / not-in-source: because
SessionIdis a value-typed alias with no "absent" representation, a caller that omits the optional query parameter model-binds it to0rather than being rejected. That is the exact shapeRemoveSessionCategoryItemHandlerhas 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:CreateSessionHandlerclosesCreateEntityHandlerBase<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, andISessionFieldsRequest(SessionCreateRequest.cs:11); theSessiontype for the prefix; theSessionIdentifierType,EventIdentifierType, andRoomIdentifierTypealiases. - Concept introduced, the request DTO as the command:
ICreateRequestis a pure marker with no members; its only job is to be a generic constraint onIEntityRequestMapper<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 inAggregateRootEntityControllerBasebind 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:
ISessionFieldsRequestdeclares the seven session fields the create and update requests validate identically,Title,Description,Status,LiveUrl,RecordingUrl,AccessibilityInfo, andResourceLinks(MMCA.ADC.Conference.Application/Sessions/Validation/ISessionFieldsRequest.cs:13-35). Implementing it is what letsSessionFieldRules<T>declare the shared rule list once over a constrainedT(SessionValidationRules.cs:111-124). Fields validated by only one operation stay off the interface deliberately:EventIdis 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. Seventeeninit-only data properties follow. Only two arerequired:Title(:20) andEventId(:62).Id(:17) is a plain non-nullableSessionIdentifierTypedocumented as "auto-generated if not provided", which in practice means a caller sends nothing and the property arrives as0;CreateSessionHandlerreads that0as its signal to allocate an id from the reserved manual range.LiveUrl,RecordingUrl,AccessibilityInfo, andResourceLinks(:47,:50,:53,:56) are nullable strings rather thanUri, 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 casePublicSessionStatusSpecificationhas to allow for).[Rubric §15, Best Practices and Code Quality]:init-only accessors make the request immutable once bound, andCreateSessionHandleruseswithrather than mutation when it fills in the id (CreateSessionHandler.cs:94). - Where it's used: bound from the body of
POST /SessionsonSessionsController(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 bySessionCreateRequestValidator; turned into an entity bySessionCreateRequestMapper; handled byCreateSessionHandler. - Caveats / not-in-source:
Duration(:59) never reaches the domain. It is not among the argumentsSessionCreateRequestMapperpasses toSession.Create(SessionCreateRequestMapper.cs:19-35), andSession.Createhas 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 fromStartsAtandEndsAt(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:
NotEmptycovers 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 theQuestionrow and therefore lives behind the handler as BR-124, inSessionQuestionAnswerRules.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 byAddSessionQuestionAnswerCommandValidatorTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:9). - Caveats / not-in-source: neither
SessionIdnorQuestionIdis validated here, so a zero id passes validation and fails later, the session id as aNotFoundfrom 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-20names the rules: BR-91, BR-49, BR-108, BR-128, BR-124, BR-107). - Depends on:
ICommandHandler<in TCommand, TResult>closed over the command andResult<SessionQuestionAnswerDTO>(AddSessionQuestionAnswerHandler.cs:26);IUnitOfWork(:21);ICurrentUserService(:22); the concreteSessionQuestionAnswerDTOMapper(:23); the BCLTimeProvider(:24);SessionQuestionAnswerRulesfor the rule chain (:69,:80); theSession,Event, andQuestionaggregates;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 callssession.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 injectedTimeProvider(:24) rather than readingDateTime.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.
SessionQuestionAnswerRulesis 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 itsSessionQuestionAnswersandasTracking: true(:32-37), because both the upsert lookup and the subsequent mutation need the children tracked. A missing session is a stampedError.NotFound(:38-39).ValidateSessionEligibilityAsync(:62-71) reads the parentEventthrough its own repository (:66-67) and hands both aggregates toSessionQuestionAnswerRules.EnsureSessionAcceptsFeedback(:69-70), which chainsSessionInvariants.EnsureNotServiceSession(BR-91, a break or a lunch slot takes no feedback,SessionQuestionAnswerRules.cs:27, defined atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:94),SessionInvariants.EnsureStatusIsEligible(BR-49, the same allow-listPublicSessionStatusSpecificationexpresses for reads,SessionQuestionAnswerRules.cs:34, defined atSessionInvariants.cs:109), a null-eventNotFound(SessionQuestionAnswerRules.cs:41-44), andEventInvariants.EnsureEventIsPublished(BR-108,SessionQuestionAnswerRules.cs:46, defined atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/EventInvariants.cs:151).ValidateQuestionAsync(:73-82) loads theQuestion(:77-78) and delegates toSessionQuestionAnswerRules.EnsureAnswerIsValid(:80-81), which rejects a question that does not exist or whoseQuestionEntityis not"Session"with a validation error codedQuestion.NotFoundOrWrongEntity(BR-128,SessionQuestionAnswerRules.cs:59-66), then hands the answer text toQuestionInvariants.EnsureAnswerValueMatchesQuestionType(BR-124,SessionQuestionAnswerRules.cs:69-70, defined atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:118).UpdateExistingAnswerAsync(:84-97) callssession.UpdateSessionQuestionAnswer(existingAnswer.Id, command.AnswerValue)(:90, aggregate method atSession.cs:508), saves, and maps the same tracked instance back out, so the response carries the new value.CreateNewAnswerAsync(:99-117) callssession.AddSessionQuestionAnswer(...)(:105, aggregate method atSession.cs:484), raises the integration event, saves, and maps the child the aggregate returned. Both branches emit the sameLogQuestionAnswerAddedToSessionline (: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
Resultthat folds into the same failure channel (ADR-013). The branch on an existing answer is also why this slice keeps a hand-writtenHandleAsyncinstead of subclassingAddChildEntityHandlerBase<TCommand, TParent, TIdentifierType, TChild, TChildDTO>: that base'sApplyhook 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 throughIUnitOfWorkrepositories, 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 intoSessionQuestionAnswersController(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 /SessionQuestionAnswersaction (:166-173). Covered byAddSessionQuestionAnswerHandlerTests(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 theSpeakerIdentifierTypealias for thedefaultcomparison. - Concept reinforced, validation at the command boundary: the validation decorator runs every registered
AbstractValidator<TCommand>before the handler (ADR-014), which is whyAddSessionSpeakerHandlercontains no shape checks on its inputs. Discovery is by convention: the module'sScanModuleApplicationServices<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 structuredResultrather 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 isint,defaultis0, 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 codeSession.Speaker.Duplicateat: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 ofAddSessionSpeakerHandler. Covered byAddSessionSpeakerCommandValidatorTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCommandValidatorTests.cs:87). - Caveats / not-in-source:
SessionIdis not validated. A command withSessionId == 0passes validation and fails asError.NotFoundfrom 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 overAddSessionSpeakerCommand,Session,SessionIdentifierType,SessionSpeaker, andSessionSpeakerDTO(AddSessionSpeakerHandler.cs:20);IUnitOfWork, passed to the base (:17,:20); the concreteSessionSpeakerDTOMapper(: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, failNotFoundwhen it is gone, callApply, save only on success, log, and answer withMapChild. 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 concreteSessionSpeakerDTOMapperrather than through the mapper interface, which is what makes the hand-written pluralMapToDTOsreachable 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) projectscommand.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 withSession.Speaker.Duplicate(Session.cs:322), delegates row creation toSessionSpeaker.Create, and raisesSessionSpeakerChangedwithDomainEntityState.Added(Session.cs:336). A failure short-circuits before the save (ChildEntityHandlerBase.cs:115-116).MapChild(:33) turns the returnedSessionSpeakerinto 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 toLogSpeakerAddedToSession, 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).
- Constructor parameters (
- Why it's built this way: returning the created DTO rather than bare success lets the controller answer
201 Createdwith 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 intoSessionSpeakersControllerasaddHandler(SessionSpeakersController.cs:50) and called atSessionSpeakersController.cs:169-171, after which the controller evicts the sessions output-cache tags (SessionSpeakersController.cs:181). Covered byAddSessionSpeakerHandlerTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/AddSessionSpeaker/AddSessionSpeakerHandlerTests.cs:12). - Caveats / not-in-source: nothing here checks that
SpeakerIdrefers 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 aResult. The handler also leavesOnAddedAsync(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 overGetNowNextQueryandResult<NowNextDTO>(GetNowNextHandler.cs:22);IUnitOfWork(:21);TimeProviderfrom the BCL (:22);CalendarExportMapperforIsExportableandToUtc(:1,:48,:81-82);CurrentEventSelectorfor the live window and the current-or-next rule (:68,:101); theEventandSessionaggregates;NowNextSessionDTO. - Concept introduced, injecting the clock: the handler takes
TimeProviderand readstimeProvider.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 howGetNowNextHandlerTestspins 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; aDateTime.UtcNowin 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
TimeZonestring to aTimeZoneInfo(:45) and converts each session's stored wall-clockStartsAtandEndsAtto aDateTimeOffsetthroughCalendarExportMapper.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).NowNextSessionDTOthen 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-44states the reasoning:EventInvariants.EnsureTimeZoneIsValidguards 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: aTimeZoneNotFoundExceptionhere 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 callsSelectEventAsyncand refuses anything missing or unpublished withError.NotFoundtargetingEvent(: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-includedRooms(: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 singleSessionStatuses.IsEligibleallow-list (CalendarExportMapper.cs:26-28). One definition, two public surfaces. nowis every row whose UTC window contains the instant, ordered by start then room name case-insensitively (:52-56).nextis 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).isLivecompares the instant against the event's live window fromCurrentEventSelector.GetLiveWindowUtc(:68-69, defined atMMCA.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 returningnullwhen the session has no room (:78).SelectEventAsync(:84-107) branches on the nullable id: an explicit id is a directGetByIdAsyncincludingRooms(:92-94); otherwise it loads all published events with their rooms (:97-99) and hands them toCurrentEventSelector.SelectCurrentOrNextwith accessor lambdas for start, end, and zone (:101-106, defined atCurrentEventSelector.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.icsdownload 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 intoEventsControllerasnowNextHandler(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 byNowNextService(MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/Services/HappeningNow/NowNextService.cs:14), rendered byHappeningNow, and read by the AndroidNowNextWidgetProvider. Covered byGetNowNextHandlerTests. - Caveats / not-in-source: the query at
:38-40loads every session row for the event, then filters, projects, sorts, and buckets in memory (:47-66). Nothing is pushed to the database beyond theEventIdpredicate, 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,ToRowdereferencessession.StartsAt!andsession.EndsAt!(:79-82); that is safe only becauseIsExportablealready 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
SessionCreateRequestinto aSessionentity by calling the domain factory. It is one method long and contains no logic of its own. - Depends on:
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>closed overSession/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.Createremains the only path into a validSession.[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 withArgumentNullException.ThrowIfNull(:17), then returnsTask.FromResult(Session.Create(...))with fourteen positional arguments plusisInformed:andisConfirmed:passed by name (:19-35). The two named arguments matter:Session.Createdeclares them as trailing optional parameters after the optionalroomId(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 returnsResult.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 intoCreateSessionHandlerthrough the interface, not the concrete type (CreateSessionHandler.cs:25), and invoked by the framework'sCreateCoreAsync(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/CreateEntityHandlerBase.cs:90). Covered bySessionCreateRequestMapperTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/Create/SessionCreateRequestMapperTests.cs:6). - Caveats / not-in-source: the mapper passes
request.Idinto aSessionIdentifierType?parameter (SessionCreateRequestMapper.cs:20), so a request whoseIdis still0would reach the factory as0rather than asnull. That does not happen on the live path, becauseCreateSessionHandlerreplaces a default id with a computed one in itsPrepareAsyncoverride before the mapper runs (CreateSessionHandler.cs:79-95), but the mapper itself does not enforce it. See also theDurationproperty this method drops, noted underSessionCreateRequest.
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 twoIncludecalls and nothing else (SessionCreateRequestValidator.cs:9-17). - Depends on: FluentValidation's
AbstractValidator<T>(:1,:7);SessionFieldRules<T>andSessionEventIdRules<T>fromMMCA.ADC.Conference.Application.Sessions.Validation(:2). - Concept reinforced, composing validators with
Include, and where the shared set stops: FluentValidation'sIncludefolds 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, insideSessionFieldRules<T>, which is generic overT : ISessionFieldsRequestand 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, inSessionTitleRules<T>viaSessionFieldRules<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 bySessionInvariantsconstants.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 andUpdateSessionHandlerrejects a change instead. The rule itself is aRequiredIdRulesspecialization carrying the error codeSession.EventId.Required(SessionValidationRules.cs:24-29).
- Why it's built this way: the parallel
SessionUpdateRequestValidatorincludes the sameSessionFieldRules<T>closed over its own request type and adds a different delta. Parameterizing the shared set by the request type, constrained toISessionFieldsRequest, 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 ofCreateSessionHandler(ADR-014). Covered bySessionCreateRequestValidatorTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/Validation/SessionCreateRequestValidatorTests.cs:7). - Caveats / not-in-source: the validator says nothing about
StartsAtversusEndsAt. Ordering is a domain invariant, checked bySessionInvariants.EnsureEndsAtIsAfterStartsAtinside the factory (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:185, defined atSessionInvariants.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 overRemoveSessionCategoryItemCommand,Session, andSessionIdentifierType(RemoveSessionCategoryItemHandler.cs:16);IUnitOfWork, passed to the base (:14,:16);IRepository<TEntity, TIdentifierType>as theLoadAsyncparameter type (:26);Result; theSessionCategoryItemchild. - Concept reinforced: the shared workflow taught on
AddSessionSpeakerHandler, minus the DTO. Removals return a bareResultbecause the controller answers204 No Content(SessionCategoryItemsController.cs:202), so there is nothing to map and no mapper to inject. The base chains throughMutateEntityHandlerBasetoMutateCoreAsync(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:271-308), which loads, guardsNotFound, mutates, saves, and logs;RemoveChildEntityHandlerBaseadds only one thing, re-declaringIncludesas abstract, because a remove that cannot see the collection cannot find the child and would report a wrongNotFound(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
SessionIdto0without a 400 (RemoveSessionCategoryItemHandler.cs:32-34). This is exactly the case the base'sLoadAsynchook exists for: it isvirtual, 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) projectscommand.SessionId, used by the default load path.LoadAsync(:25-49) is the branch above, guarded byArgumentNullException.ThrowIfNull(repository)(:30) and reusing the inheritedIncludesandAsTrackingin both arms so the two paths cannot drift.AsTrackingistrueby default (MutateEntityHandlerBase.cs:76), which is required because the mutation soft-deletes a loaded child.MutateAsync(:52-56) is one line wrapped inTask.FromResult:entity.RemoveSessionCategoryItem(command.SessionCategoryItemId)(aggregate method atMMCA.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-generatedLogCategoryItemRemovedFromSession(: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
LoadAsyncoverride rather than in a hand-written handler is what keeps the rest of the workflow (theNotFoundshape, the save ordering, the optimistic-concurrency stamp atMutateEntityHandlerBase.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 intoSessionCategoryItemsControllerasremoveHandler(SessionCategoryItemsController.cs:51) and called atSessionCategoryItemsController.cs:192-194, after which the controller evicts the sessions, categories, and conference output-cache tags (SessionCategoryItemsController.cs:201). Covered byRemoveSessionCategoryItemHandlerTests(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
SessionIdon 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 usesFirstOrDefaultAsyncover 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 recordscommand.SessionId(:60), which is0on 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 overSessionCreateRequest,Session,SessionIdentifierType, andSessionDTO(CreateSessionHandler.cs:29-30);IUnitOfWork(:23);IServiceScopeFactoryfromMicrosoft.Extensions.DependencyInjection(:24);IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>(:25, implemented bySessionCreateRequestMapper); the concreteSessionDTOMapper(:26);IUniqueConstraintViolationDetector(:27);SessionInvariantsfor the reserved range;SessionRoomSchedulingfor BR-130; theEventaggregate. - Concept introduced, application-assigned ids in a reserved range: session primary keys are not database-generated, because the
intPK 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.ManualIdRangeStartis999_999_000andManualIdRangeEndis999_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 withignoreQueryFilters: true(:81-85), takesMax + 1or the range start when empty (:87-89), and fails with a plainError.FailurecodedSession.ManualIdRangeExhaustedwhen 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 + 1and 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.MaxManualIdAttemptsis3(:33). TheHandleAsyncoverride (:36-66) loops, and thecatchfilter 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 freshIUnitOfWork(:56-57), because the ambientDbContextstill 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 overridesHandleAsyncto wrapCreateCoreAsyncin its retry loop and overridesPrepareAsyncto recompute the id per attempt, andCreateCoreAsynctakes 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.Number2601 or 2627, nor the EF CoreDbUpdateExceptionthat wraps them. Rather than match on the exception message, which is a provider and locale detail, the question is declared asIUniqueConstraintViolationDetectorand 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 inheritedCreateCoreAsyncon 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 incommand = 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 parentEventwith itsRoomsuntracked (:102-107), returnsError.NotFoundtargetingEventwhen it is missing (:108-109), and delegates toSessionRoomScheduling.ValidateRoomAssignmentAsyncfor BR-130 cross-event validation plus the double-booking guard (:111-119, defined atMMCA.ADC.Conference.Application/Sessions/Validation/SessionRoomScheduling.cs:44, withexcludeSessionId: nullbecause nothing exists yet to exclude). The comment at:97-99explains 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
PrepareAsyncis the base'sCreateCoreAsync(CreateEntityHandlerBase.cs:77-103): map through the request mapper,AddAsyncthenSaveChangesAsyncviaPersistAsync, callLogCreated, then map theSessionDTO. LogCreated(:128) is the base's post-save hook, forwarding to the module's template. Two[LoggerMessage]partials close the file:LogSessionCreatedat information level (:130-131) andLogManualIdCollisionat 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 intoSessionsControllerascreateHandler(SessionsController.cs:47), passed intoAggregateRootEntityControllerBase(SessionsController.cs:57-58), and called from the overriddenPOST /Sessionsaction (SessionsController.cs:285), which is marked[Idempotent]so a retried request replays rather than creating twice (SessionsController.cs:280, ADR-017). Covered byCreateSessionHandlerTests(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
Maxin 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 alsowhile (true)with its bound expressed only in thecatchfilter (: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
SessionIdand theSessionQuestionAnswerId(MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionQuestionAnswer/RemoveSessionQuestionAnswerCommand.cs:9-11). - Depends on:
ICacheInvalidating(RemoveSessionQuestionAnswerCommand.cs:11), theSessiontype for the prefix, and theSessionIdentifierType/SessionQuestionAnswerIdentifierTypealiases (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 insideRemoveSessionQuestionAnswerHandler(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, andCachingCommandDecorator<TCommand, TResult>does the work on success only (ADR-014). - Where it's used: constructed by the
DELETE /SessionQuestionAnswers/{id}action ofSessionQuestionAnswersController, 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 theSessionsManagepermission the other two junction controllers demand; handled byRemoveSessionQuestionAnswerHandler, injected atSessionQuestionAnswersController.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
SessionIdand theSessionSpeakerIdjoin row (MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerCommand.cs:9-11). - Depends on:
ICacheInvalidating(RemoveSessionSpeakerCommand.cs:11), theSessiontype for the prefix, and theSessionIdentifierType/SessionSpeakerIdentifierTypealiases. - Concept reinforced: structurally identical to
RemoveSessionCategoryItemCommand. Note the asymmetry with the matching Add command:AddSessionSpeakerCommandtakes 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:RemoveSessionSpeakerHandlertreats aSessionIdofdefaultas "not supplied" and resolves the owning session from the join id instead (RemoveSessionSpeakerHandler.cs:35-42), which is only possible becauseSessionIdentifierTypeis 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 ofSessionSpeakersController, 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 theSessionsManagepermission (SessionSpeakersController.cs:47, ADR-020); handled byRemoveSessionSpeakerHandler, injected atSessionSpeakersController.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 /Sponsorsbinds 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:ICreateRequestandICacheInvalidating. The third,ISponsorFieldsRequest, is the module's own (MMCA.ADC.Conference.Application/Sponsors/Validation/ISponsorFieldsRequest.cs:12). It also depends on theSponsorentity type (referenced only throughtypeoffor the cache prefix) and theSponsorTierenum.SponsorIdentifierTypeandEventIdentifierTypeare both module aliases forint. - 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 theTCreateRequestargument 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:
ISponsorFieldsRequestdeclares the eight members the create and the update request validate identically (ISponsorFieldsRequest.cs:15-36). It exists purely soSponsorFieldRules<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, andIsExhibitorare absent. The file states the reason forEventId(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 byCachingCommandDecorator<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 consultstypeof(Sponsor).IsIdValueGeneratedand substitutesdefaultwhenever 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) isrequired, 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) andBoothNumber(:51) the expo-floor pair, andLogoUrl,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
SponsorDTOmember 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:
SponsorsControllertakes the handler for it in its constructor (SponsorsController.cs:42) and overridesCreateAsyncto add theSponsorsManagepermission attribute and the output-cache eviction (SponsorsController.cs:162-171);SponsorCreateRequestValidatorvalidates it,SponsorCreateRequestMapperturns it into an entity, andCreateSponsorHandlerpersists 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.
CachePrefixdrives 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
SponsorintoSponsorDTO, 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 overSponsor/SponsorDTO/SponsorIdentifierType(:14), and the Mapperly source generator via[Mapper]fromRiok.Mapperly.Abstractions(:4,:12). - Concept reinforced, source-generated mapping: the pattern is introduced in Group 12 and governed by ADR-001.
MapToDTOis declaredpartialwith 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. BecauseSponsorDTOalso carries aRowVersion, 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 intoCreateSponsorHandler(MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/CreateSponsorHandler.cs:18), which the framework's Scrutor scan supports by registering mappersAsSelfWithInterfaces(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.
SpeakerDTOMapperinjects 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 theIEntityDTOMapper<,,>sweep atMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:200-204); injected concretely intoCreateSponsorHandler(CreateSponsorHandler.cs:18) and forwarded from there into the framework create workflow'sdtoMapperslot (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
MapToDTOhas no null guard, and the suite pins that:MapToDTO(null!)throwsNullReferenceException(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 conventionalArgumentNullException(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 theSessiontype, referenced only throughtypeofto build the cache prefix (:16).SessionIdentifierTypeandSessionQuestionAnswerIdentifierTypeare bothint. - 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 requiresSessionId, supplied from the request body (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:219). That is not redundancy:SessionQuestionAnsweris a child inside theSessionaggregate, 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 letsUpdateSessionQuestionAnswerHandlerkeep 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, andAnswerValue(: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.
- The three positional parameters
- Why it's built this way: the handler's response type is a bare
Result, notResult<T>, because a successful update returns nothing beyond a204(SessionQuestionAnswersController.cs:222-224). That choice is what selects the three-parameterMutateEntityHandlerBase<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 byUpdateSessionQuestionAnswerCommandValidator, and handled byUpdateSessionQuestionAnswerHandler. - Caveats / not-in-source: nothing checks that the
SessionIdin 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 parentEventreference 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 overActivity(:14),FKNavigationDescriptor<TEntity, TChild, TChildId>closed overActivity/Event/EventIdentifierType(:16),IUnitOfWorkforwarded untouched to the base (:12-14), and theActivityandEvententities. - 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.RequiresChildrenis hard-codedfalse(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,includeFKsrather thanincludeChildren(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 ordinalHashSetof 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; withIsCollectionleft 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 makesRequiresChildren => falsethe right gate.ParentKeySelector = e => e.EventId(:19). The descriptor types this asFunc<TEntity, TChildId?>(FKNavigationDescriptor.cs:26), andActivity.EventIdis a non-nullableint(Activity.cs:54), so the compiler widens it toint?. 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 matchesEvent.Idagainst the set ofActivity.EventIdvalues.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 aprivate 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), buildchild => parentIds.Contains(child.Id)as an expression tree (:71), run oneGetAllAsyncwith 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:
PopulateAsyncreturns 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
NavigationMetadataProviderdecides 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 batchedINshape 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-genericEntityQueryService<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:
AssignActionruns for every parent, including those whose lookup found nothing, in which caseFirstOrDefault()assignsnull(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 withEvent == nullrather 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 parentCategoryreference. It is the only populator in the Conference module whose FK target is notEvent. - Depends on:
DeclarativeNavigationPopulator<TEntity>overCategoryItem(MMCA.ADC.Conference.Application/Categories/CategoryItemNavigationPopulator.cs:13), oneFKNavigationDescriptor<TEntity, TChild, TChildId>closed overCategoryItem/Category/ConferenceCategoryIdentifierType(:15), andIUnitOfWork(:12). - Concept reinforced: identical in mechanism to
ActivityNavigationPopulator, which teaches the FK descriptor, theincludeFKsgate, 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; andAssignAction = (e, categories) => e.SetCategory(categories.FirstOrDefault())(:20), which goes through the entity's public mutator (CategoryItem.cs:89) because the navigation has aprivate set. The generic argumentConferenceCategoryIdentifierTypeis the module alias forint, named with theConferenceprefix because the entity type is the very genericCategory. - 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:
ConferenceCategoryNavigationPopulatoris 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
Categoryaggregate, 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>overCategory(:13), oneChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>closed overCategory/ConferenceCategoryIdentifierType/CategoryItem/CategoryItemIdentifierType(:15),IUnitOfWork(:12), and theCategoryandCategoryItementities. - Concept introduced in this group, declarative child loading: the counterpart to the FK direction taught on
ActivityNavigationPopulator. Three things change. First,ChildNavigationDescriptor.RequiresChildrenis hard-codedtrue(MMCA.Common/Source/Core/MMCA.Common.Application/Services/Navigation/ChildNavigationDescriptor.cs:25), so the load is gated on the caller'sincludeChildrenflag (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 asIReadOnlyCollection<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 atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Categories/CategoryItem.cs:27).AssignAction = (e, categoryItems) => e.SetCategoryItems(categoryItems)(:20) calls aninternalaggregate mutator (Category.cs:201), reachable from this assembly only because the Domain project grantsInternalsVisibleTotoMMCA.ADC.Conference.Application. Nothing outside the module can replace the collection.- The load routes through
NavigationLoader.LoadChildrenPropertyAsync(ChildNavigationDescriptor.cs:41, implemented atMMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:118), which is one batchedWHERE CategoryId IN (...parentIds)query per descriptor, not one per category.
- Why it's built this way: the naming deserves a note. The type is
ConferenceCategoryNavigationPopulatorwhile the entity is plainCategory, and the identifier alias isConferenceCategoryIdentifierType:Categoryis 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 overSponsorCreateRequest/Sponsor/SponsorIdentifierType/SponsorDTO(:20-21);IUnitOfWork(:16),IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>(:17, satisfied at runtime bySponsorCreateRequestMapper), and the concreteSponsorDTOMapper(:18), all three forwarded straight into the base constructor; plusILogger<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 declaresLogCreatedas 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 ispartial: 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 callsCreateCoreAsync(:76-102), which runs:PrepareAsync(:83, a pass-through by default,:113-117),requestMapper.CreateEntityAsyncwith an early exit on a domain failure (:89-91),attemptUnitOfWork.GetRepository<Sponsor, SponsorIdentifierType>()(:94),PersistAsync(:96, which isAddAsyncthenSaveChangesAsync,:137-138), thenLogCreated(:98),OnCreatedAsync(:99, a no-op here), and finallyResult.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 byValidatingCommandDecorator<TCommand, TResult>resolvingSponsorCreateRequestValidator; invalidation is applied byCachingCommandDecorator<TCommand, TResult>readingCachePrefixoff the request; the decorator ordering is registered atMMCA.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 familiesAsSelfWithInterfaces(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 fromMMCA.ADC.Conference.Application/DependencyInjection.cs:133) and injected intoSponsorsController(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:42), reached through the baseCreateAsyncthat the controller's permission-gated override wraps (SponsorsController.cs:162-171). The generic CRUD registration for sponsors runs after the scan and usesTryAdd, 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
EventIdnames a real event.SponsorCreateRequestValidatoronly requires it to be non-default throughSponsorEventIdRules<T>(MMCA.ADC.Conference.Application/Sponsors/Validation/SponsorValidationRules.cs:115-120), the mapper performs no lookup, andSponsor.Createvalidates 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 toEvent(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sponsors/SponsorConfiguration.cs:59-65), so a bogus id fails atSaveChangesAsyncas a database error rather than as a validationResult.
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:
IUnitOfWorkand the read-sideIEntityQuerier<TEntity, TIdentifierType>it hands out (:40,:75,:126-127,:151),CrossSourceSpecification(:63),InlineSpecification<TEntity, TIdentifierType>(:149),PublicSessionStatusSpecification(:68,:148), and theEvent,Session, andSessionSpeakerentities. - 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 anIN, 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 asIEntityQuerier<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 theGetRepositorythe 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 toCrossSourceSpecification.BuildAsync(:63-70), passinge => e.IsPublishedas the principal predicate,s => s.EventIdas the dependent FK, andPublicSessionStatusSpecification.StatusCriteriaas the local predicate. That helper runs the principal projection first and returns a specification whose criteria islocalPredicate AND principalKeys.Contains(fk), built as an expression tree with noExpression.Invokeso 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 witheligibleSessionIds.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 translatableINfilter with no navigation join (:146-147).
- Why it's built this way: the remark at
:97-103is the most load-bearing comment in the file, and it records a real rule, not a preference. TheEventSpeakerjunction 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), andGetPublicEventSpeakerFilterHandler(.../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 toGetVisibleSpeakerIdsAsyncissues 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 overRemoveSessionQuestionAnswerCommand/Session/SessionIdentifierType(:24);IUnitOfWork, forwarded to the base (:21,:24);ICurrentUserService(:22);ILogger<RemoveSessionQuestionAnswerHandler>(:23);RoleNamesfor theOrganizerconstant (:71);IRepository<TEntity, TIdentifierType>, which the base passes into the load hook (:34); theSessionQuestionAnswerchild; andResult/Error. - Concept introduced, the load-mutate-save workflow as a base class: this handler declares no
HandleAsyncof 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), returnError.NotFoundstamped with the handler name and the entity name when the aggregate is gone (:281-282), stamp the caller'sRowVersionwhen the endpoint is conditional (:290-291, ADR-035), run the mutation (:293), short-circuit on failure (:294-295), honor aSkipSaveno-op (:299-300), save (:302), then log (:304) and run the post-commit hook (:305). TheMutateEntityHandlerBaselayer on top adds only the return shape, a bareResult(: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 theOrganizerrole, and the answer'sCreatedBydiffers from the current user id, the handler returnsError.Forbiddenwith the codeSessionQuestionAnswer.NotOwnerand 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.AsTrackingis left at the base default oftrue(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. Whencommand.SessionId == default(:44) the handler resolves the owning session from the join id withFirstOrDefaultAsync(s => s.SessionQuestionAnswers.Any(a => a.Id == command.SessionQuestionAnswerId), ...)(:46-50); otherwise it loads directly by id (:53-57). Both branches pass the sameIncludesandAsTracking, 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 toentity.RemoveSessionQuestionAnswer(command.SessionQuestionAnswerId)(:80). The aggregate method soft-deletes the child through the sharedRemoveChildOrNotFoundhelper and raisesSessionQuestionAnswerChangedwithDomainEntityState.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 ispartial.
- 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.
MutateAsyncis 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 intoSessionQuestionAnswersControllerasremoveHandler(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:81) and called atSessionQuestionAnswersController.cs:234-236. Covered byRemoveSessionQuestionAnswerHandlerTests(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 whenanswerisnull(: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 fromMutateEntityHandlerBaseand not from the narrowerRemoveChildEntityHandlerBaseits 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
SponsorCreateRequestinto aSponsor. It does not construct the entity itself; it calls the domain factory and hands back whateverResult<Sponsor>that factory returns (MMCA.ADC.Conference.Application/Sponsors/UseCases/Create/SponsorCreateRequestMapper.cs:19-31). - Depends on:
IEntityRequestMapper<TEntity, TCreateRequest, TIdentifierType>closed overSponsor/SponsorCreateRequest/SponsorIdentifierType(:12), theSponsoraggregate and itsCreatefactory, andResult(:3). - Concept reinforced, request mapping is not object mapping:
SponsorDTOMappercan be source-generated because its target is a settable record. This mapper cannot, because its target is a factory that returns aResult<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 aSponsorthat skipsSponsor.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 noawaitbecause there is no I/O: the method isTask-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 onIsIdValueGenerated(:126-131), and raisesSponsorChangedwithDomainEntityState.Addedbefore returning success (:133). request.Idis a non-nullableintwidened to the factory'sSponsorIdentifierType?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 fromMMCA.ADC.Conference.Application/DependencyInjection.cs:133), forwarded fromCreateSponsorHandlerinto the base, and called there atMMCA.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
CreateSponsorHandlerfor what does and does not verifyEventId. The base'sPrepareAsynchook (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 twoIncludecalls 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), andSponsorEventIdRules<T>(:15). - Concept reinforced, composable rule objects, one level up: the
Includetechnique is introduced in Group 06; what this file shows is the technique applied twice.Includemerges another validator's rules into this one, andSponsorFieldRules<T>is itself nothing but eightIncludecalls over theISponsorFieldsRequestmembers (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:SponsorUpdateRequestValidatoris a singleIncludeof 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.
Include(new SponsorFieldRules<SponsorCreateRequest>())(:11) pulls in the eight shared rules (SponsorValidationRules.cs:145-152):SponsorNameRules<T>overName, aRequiredStringRules<T>with the label "Sponsor Name" andSponsorInvariants.NameMaxLength(:13-18);SponsorSortRules<T>, aNonNegativeIntRules<T>with the error codeSponsor.Sort.Negative(:126-131); three URL rules,SponsorLogoUrlRules<T>(:26-36),SponsorWebsiteUrlRules<T>(:56-66), andSponsorLinkedInUrlRules<T>(:74-84), each of which wrapsAbsoluteUrlRules<T>in aWhenclause so an empty value passes but a supplied one must be an absolute http or https URL as well as within its max length; and threeOptionalStringRules<T>derivations,SponsorDescriptionRules<T>(:43-48),SponsorTwitterHandleRules<T>(:91-96), andSponsorBoothNumberRules<T>(:103-108). Every max length comes from the domain'sSponsorInvariantsconstants, so the request rule and the entity invariant cannot disagree.Include(new SponsorEventIdRules<SponsorCreateRequest>(p => p.EventId))(:15) is the create-only delta, and the comment above it says why (:13-14): the owning event is chosen once, at creation, and the update request does not carry it, so moving a sponsor between events is a create plus a delete. The rule derives fromRequiredIdRules<T, TId>with the field phrase "an Event for the Sponsor" and the error codeSponsor.EventId.Required(SponsorValidationRules.cs:115-120).
- 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 aResultrather 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 atMMCA.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.
NotEmptyon anintrejects 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 constrainsTier: an out-of-range enum value passes validation, andSponsor.Createdoes 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) andQuestionInvariantsfor 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 inQuestionInvariants.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
RuleForchain (:19-25).NotEmpty()with the message "Answer value is required." and the error codeSessionQuestionAnswer.AnswerValue.Required(:20-22).MaximumLength(QuestionInvariants.TextAnswerMaxLength)with the codeSessionQuestionAnswer.AnswerValue.TooLong(:23-25). The constant is2000(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,
AddSessionQuestionAnswerCommandValidatoron 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 atMMCA.ADC.Conference.Application/DependencyInjection.cs:133) and run byValidatingCommandDecorator<TCommand, TResult>ahead ofUpdateSessionQuestionAnswerHandler. - 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
SessionIdorSessionQuestionAnswerId; a zero or unknown id is left to the handler's load, which answersNotFound.
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, andSessionIdentifierType(:24);IUnitOfWork(:21);ICurrentUserService(:22);ILogger<UpdateSessionQuestionAnswerHandler>(:23);RoleNames(:43); andResult/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 whoseCreatedBymatches 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 inheritedAsTrackingdefault oftrue(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/MutateEntityHandlerBase.cs:76). Tracking is load-bearing: the mutation happens on this graph andSaveChangesAsyncpersists it only because the change tracker is watching.EntityId(command) => command.SessionId(:30). Unlike its remove siblings this handler does not overrideLoadAsync, 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 becomesError.NotFoundin 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 witha.Id == command.SessionQuestionAnswerId && !a.IsDeleted(:42), and fails withError.Forbidden(code: "SessionQuestionAnswer.NotOwner", ...)when the answer exists, the caller is not inRoleNames.Organizer, andanswer.CreatedBydiffers from the current user id (:43-50). Theanswer is not nullguard 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, intoMMCA.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, callanswer.UpdateAnswer(which validates the text before assigning it,Session.cs:517-519), and raiseSessionQuestionAnswerChangedwithDomainEntityState.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 ispartial.
- 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 withOwnedByUserSpecification<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 intoSessionQuestionAnswersControllerasupdateHandler(SessionQuestionAnswersController.cs:80) and called at:218-220. Covered byUpdateSessionQuestionAnswerHandlerTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/UpdateSessionQuestionAnswer/UpdateSessionQuestionAnswerHandlerTests.cs:14). - Caveats / not-in-source: the handler declares no
RowVersionoverride, 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 noIf-Matchprecondition, 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 overRemoveSessionSpeakerCommand/Session/SessionIdentifierType(MMCA.ADC.Conference.Application/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandler.cs:16);IUnitOfWorkforwarded to the base (:14,:16);ILogger<RemoveSessionSpeakerHandler>(:15);IRepository<TEntity, TIdentifierType>as the load hook's parameter (:26); theSessionSpeakerchild; andResult. - Concept introduced, the remove-a-child specialization:
RemoveChildEntityHandlerBaseis a thin layer overMutateEntityHandlerBase<TCommand, TEntity, TIdentifierType>that changes exactly one thing: it re-declaresIncludesasprotected abstract override(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Crud/ChildEntityHandlerBase.cs:152). On the general baseIncludesis virtual and defaults to empty; here the compiler refuses to let a subclass omit it. The reason is stated atChildEntityHandlerBase.cs:130-133: a remove that cannot see the child collection cannot find the child and reports a wrongNotFound.[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
SessionIdto the default0without a400(RemoveSessionSpeakerHandler.cs:32-34). The handler branches on that (:35): when the session id is unset it callsFirstOrDefaultAsync(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, withArgumentNullException.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 sharedRemoveChildOrNotFoundhelper (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:373-374) and raisesSessionSpeakerChangedwithDomainEntityState.Deleted(Session.cs:379).LogMutated(:59-60) forwards to the[LoggerMessage]partial at:62-63. Everything else, the repository resolution, theNotFound, the save-only-on-success rule, comes fromMutateEntityHandlerCore.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
LoadAsyncrather than inHandleAsyncmeans 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 intoSessionSpeakersControllerasremoveHandler(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionSpeakersController.cs:51) and called atSessionSpeakersController.cs:194-196. Covered byRemoveSessionSpeakerHandlerTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sessions/UseCases/RemoveSessionSpeaker/RemoveSessionSpeakerHandlerTests.cs:12). - Caveats / not-in-source: the fallback query calls
FirstOrDefaultAsyncwith 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 recordscommand.SessionId(:60), which is0on 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 overDeleteEntityCommand<TEntity, TIdentifierType>forEventandResult(:21);IUnitOfWork(:19);IEventCascadeDeletionDomainService(:20);ILogger<DeleteEventHandler>fromMicrosoft.Extensions.Logging(:21); and the four aggregatesEvent,Session,Sponsor, andActivity. - 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 anEventId, so nothing insideEventcan 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 noIRepositoryparameter: every repository is pulled offIUnitOfWorkinside the method, which is the framework's rule for keeping one tracked context per operation. HandleAsyncloads the event throughunitOfWork.GetRepository<Event, EventIdentifierType>()with an explicitincludesarray namingRooms,EventSpeakers, andEventQuestionAnswers, and withasTracking: 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 anUPDATE. An untracked graph would produce a silently successful no-op.- A missing event short-circuits with
Error.NotFoundstamped with source and target (:34-35), which is theResultidiom 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.IsDeletedand tracked (:38-43). Sponsors (:47-52) and activities (:56-61) follow the same shape with an emptyincludesarray, 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.SaveChangesAsyncruns only when the cascade succeeded, so an aborted cascade's partial in-memory mutations are discarded with the scope rather than persisted. That singleSaveChangesAsyncis what makes the whole cascade atomic. LogEventDeletedis a source-generated[LoggerMessage]partial atInformationlevel 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.
- The primary constructor takes three services and declares the contract in the base list (
- 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 genericDeleteEntityHandler<TEntity, TIdentifierType>thatSpeakerandQuestionregister (:75,:84). It is injected intoEventsControlleras the delete handler (MMCA.ADC.Conference.API/Controllers/Events/EventsController.cs:53) and invoked through the overriddenDeleteAsync, which delegates to the base action and then evicts three output-cache tags,conference:events,conference:sessions, andconference:rooms, precisely because the cascade reached beyond the event (:368-375). Covered byDeleteEventHandlerTests, 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.IsDeletedpredicates (: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 isinternal, so nothing outside this assembly can name it, and every consumer sees only the interface. - Depends on:
IUnitOfWorkand the BCLTimeProvider(:22), theEvent,Session, andSponsorentities,SessionInvariantsfor the two eligibility rules (:67,:71),CurrentEventSelectorfor the live-window math (:227),CalendarExportMapperfor wall-clock to UTC conversion (:194-195), and the four result recordsEventLiveInfo,SessionLiveInfo,SponsorLiveInfo, andRoomSessionInfo. - 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 withservices.Replace(...)atMMCA.ADC/Source/Services/MMCA.ADC.Conference.Contracts/DependencyInjection.cs:79); and in a host that does not load Conference at all, the module registersDisabledEventLiveValidationServiceinstead (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 andasTracking: false(:30-34), returnsError.NotFoundtagged 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 itsSessionSpeakers(:55), then applies the two bookmark-eligibility rules before anything else:EnsureNotServiceSession(BR-91,:67-69) andEnsureStatusIsEligible(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-109is 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 throughCalendarExportMapper.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-200explains 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 toCurrentEventSelector.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 itsToUtchandles 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-225states 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:
TimeProvideris injected rather thanDateTime.UtcNowbeing called, which is what makes the room-resolution rules testable at all: the suite drives them withFakeTimeProviderpinned 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 includingCheckInProcessor(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), andOpenLivePollHandler(.../LivePolls/UseCases/Open/OpenLivePollHandler.cs:22). In the split topology it is exposed over gRPC byEventLiveValidationGrpcService(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.EnsureTimeZoneIsValidguards every write path, so an unresolvable stored id is a data defect that must surface rather than degrade silently (:180-181).ComputeLiveWindowUtcinherits 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 useGetReadRepository<...>()(:110,:123,:148,:167). Every call passesasTracking: false, so the reads are untracked either way; the inconsistency is in which repository is asked for, not in what the query does. GetCurrentRoomSessionInfoAsyncloads 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.
- A bad time-zone id throws.
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
Eventaggregate, the largest one in the module: three child-collection descriptors forRooms,EventSpeakers, andEventQuestionAnswers(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>overEvent(:13), threeChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>instances (:15,:22,:29),IUnitOfWorkforwarded straight to the base (:12-13), and theEvent,Room,EventSpeaker, andEventQuestionAnswerentities.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.Idagainst the child'sEventId, 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-21nameof(Event.Rooms),e => e.Id,child => child.EventId,e.SetRooms(rooms)ChildNavigationDescriptor<Event, EventIdentifierType, EventSpeaker, EventSpeakerIdentifierType>MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:22-28nameof(Event.EventSpeakers),e => e.Id,child => child.EventId,e.SetEventSpeakers(eventSpeakers)ChildNavigationDescriptor<Event, EventIdentifierType, EventQuestionAnswer, EventQuestionAnswerIdentifierType>MMCA.ADC.Conference.Application/Events/EventNavigationPopulator.cs:29-35nameof(Event.EventQuestionAnswers),e => e.Id,child => child.EventId,e.SetEventQuestionAnswers(answers)PropertyNameis written withnameofin all three (:17,:24,:31) and that is not decoration. The base builds aHashSet<string>of the query'sUnsupportedIncludesproperty names with an ordinal comparer and loads a descriptor only when itsPropertyNameis 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
internalaggregate mutators,SetRooms(MMCA.ADC.Conference.Domain/Events/Event.cs:525),SetEventSpeakers(:580), andSetEventQuestionAnswers(:654), each a thin delegation to the framework'sSetItemsover 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 asIReadOnlyCollection<>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, executingNavigationLoader.LoadChildrenPropertyAsyncatMMCA.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
includeChildrenargument, becauseChildNavigationDescriptor.RequiresChildrenistrue(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.
RoomandEventSpeakermay end up in a different physical source thanEvent, 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 byNavigationMetadataProvider, 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 anINlookup, 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 byEventNavigationPopulatorTests, which pins the type toINavigationPopulator<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.EventQuestionAnswersis deliberately not marked[Navigation](MMCA.ADC.Conference.Domain/Events/Event.cs:102-112documents 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 inUnsupportedIncludes, and the base'sunsupportedPropertyNames.Contains(descriptor.PropertyName)test never matches it (DeclarativeNavigationPopulator.cs:37). Handlers that need those answers pass an explicitincludes: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
EventQuestionAnswerread as its own entity, hydrating each answer's parentEventreference (MMCA.ADC.Conference.Application/Events/EventQuestionAnswerNavigationPopulator.cs:7-10). Empty class body (:23-24). - Depends on:
DeclarativeNavigationPopulator<TEntity>overEventQuestionAnswer(:13), oneFKNavigationDescriptor<TEntity, TChild, TChildId>closed overEventQuestionAnswer/Event/EventIdentifierType(:15), andIUnitOfWork(:12). - Concept reinforced: mechanically identical to
ActivityNavigationPopulator, which teaches the FK descriptor, theincludeFKsgate, and the batched loader;SpeakerCategoryItemNavigationPopulatorbelow walks the same four settings in detail. - Walkthrough:
PropertyName = nameof(EventQuestionAnswer.Event)(:17),ParentKeySelector = e => e.EventId(:18, the get-only FK atMMCA.ADC.Conference.Domain/Events/EventQuestionAnswer.cs:26),ChildForeignKeySelector = child => child.Id(:19), andAssignAction = (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 aList<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.EventQuestionAnswerscarries no[Navigation]attribute, so the forward collection is not auto-hydrated (see the caveat onEventNavigationPopulator), whileEventQuestionAnswer.Eventis 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 intoEventQuestionAnswersController(MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:56). Covered byEventQuestionAnswerNavigationPopulatorTests(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
EventSpeakerjunction, hydrating its parentEventreference (MMCA.ADC.Conference.Application/Events/EventSpeakerNavigationPopulator.cs:7-10). Empty class body (:23-24). - Depends on:
DeclarativeNavigationPopulator<TEntity>overEventSpeaker(:13), oneFKNavigationDescriptor<TEntity, TChild, TChildId>closed overEventSpeaker/Event/EventIdentifierType(:15), andIUnitOfWork(:12). - Concept reinforced: the FK mechanism is taught on
ActivityNavigationPopulator; the four settings here arenameof(EventSpeaker.Event)(:17),e => e.EventId(:18, the get-only FK atMMCA.ADC.Conference.Domain/Events/EventSpeaker.cs:24),child => child.Id(:19), ande.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:
EventSpeakerhas noSpeakernavigation 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 intoEventSpeakersController(MMCA.ADC.Conference.API/Controllers/Events/EventSpeakersController.cs:48). Covered byEventSpeakerNavigationPopulatorTests(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 parentEventreference (MMCA.ADC.Conference.Application/Events/RoomNavigationPopulator.cs:7-10). Empty class body (:23-24). - Depends on:
DeclarativeNavigationPopulator<TEntity>overRoom(:13), oneFKNavigationDescriptor<TEntity, TChild, TChildId>closed overRoom/Event/EventIdentifierType(:15), andIUnitOfWork(:12). - Concept reinforced: identical in mechanism to
ActivityNavigationPopulator. The settings arenameof(Room.Event)(:17),e => e.EventId(:18, the get-only FK atMMCA.ADC.Conference.Domain/Events/Room.cs:38),child => child.Id(:19), ande.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
Eventthat is read from both ends in production.EventNavigationPopulatorhydratesEvent.Roomsfor an event-first read, and this class hydratesRoom.Eventfor 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 intoRoomsController(MMCA.ADC.Conference.API/Controllers/Events/RoomsController.cs:93). Covered byRoomNavigationPopulatorTests(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
SpeakerCategoryItemjoin entity when it is read as its own entity rather than as a child of a speaker. It declares exactly one navigation, the parentSpeakerback-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 overSpeakerCategoryItem(:13);FKNavigationDescriptor<TEntity, TChild, TChildId>(:15);IUnitOfWork, forwarded straight through (:12-13); and theSpeakeraggregate 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
SpeakerNavigationPopulatoruses. AnFKNavigationDescriptorreads the key off each parent row, drops nulls, distincts them, buildschild => 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 theAssignActionends inFirstOrDefault()(:20): the loader always hands back aList<TChild>, and a reference navigation wants one element out of it. The descriptor also declaresRequiresChildren => 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'sincludeFKs/includeChildrenarguments 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'sUnsupportedIncludesproperty names with an ordinal comparer and loads a descriptor only when itsPropertyNameis in that set (DeclarativeNavigationPopulator.cs:30-37), so a typo here is a silently unpopulated navigation, not a compile error.nameofis 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), andChildForeignKeySelector = 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 anint:SpeakerIdentifierTypealiasesSystem.Guidbecause speakers carry Sessionize-assigned GUIDs (BR-61,MMCA.ADC.Conference.Shared/MMCA.ADC.Conference.GlobalUsings.IdentifierType.cs:3,:19). That satisfies the descriptor'swhere TChildId : structconstraint (FKNavigationDescriptor.cs:17), and the non-nullableSpeakerIdwidens to theTChildId?theParentKeySelectordeclares (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 genericEntityQueryService<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 intoSpeakerCategoryItemsController(MMCA.ADC.Conference.API/Controllers/Speakers/SpeakerCategoryItemsController.cs:49). Covered bySpeakerCategoryItemNavigationPopulatorTests, which pins the type toINavigationPopulator<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:
SpeakerCategoryItemDTOcarries onlyId,SpeakerId, andCategoryItemId(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,Speakerand notCategoryItem, 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 byFullName, 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 overSpeaker/SpeakerDTO/SpeakerIdentifierType(:21-22), and the five collaborators it forwards to the base unchanged (:15-20):IUnitOfWork,INavigationMetadataProvider,IEntityQueryPipeline,SpeakerDTOMapper, andINavigationPopulator<in TEntity>closed overSpeaker(satisfied at runtime bySpeakerNavigationPopulator). - 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;
FullNamedoes 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 withbuilder.Ignore(p => p.FullName)(MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerConfiguration.cs:68), so there is nothing namedFullNamefor 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 concreteSpeakerDTOMapper, not theIEntityDTOMapper<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. PropertyMapis aprivate static readonly IReadOnlyDictionary<string, string>with one entry:[nameof(SpeakerDTO.FullName)] = "(FirstName + \" \" + LastName)"(:28-31). Usingnameofkeys 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.DTOToEntityPropertyMapoverrides 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 theEntityQueryParameters<TEntity>handed to the pipeline (:295, and:527on the by-id path). On the filter path,QueryFilterServiceresolves the incoming keyFullNamethrough the map to the expression, resolves aPropertyInfofor 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). ACONTAINSthere becomesquery.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 readFirstName + " " + LastName.Contains(@0), a different expression entirely. On the sort path,QueryFieldService.ApplySortingresolves the column through the map before appending the direction and the server-supplied tie-break key, then calls Dynamic LINQOrderBy(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.PopulateAsyncas a delegate handed down to the pipeline (EntityQueryService.cs:321,:534).
- The primary constructor takes the five services and forwards them positionally to the base (
- 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:
Speakergets this subclass (MMCA.ADC.Conference.Application/DependencyInjection.cs:77) whileSponsorandSpeakerCategoryItemregister 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 intoSpeakersControlleras the interface (MMCA.ADC.Conference.API/Controllers/Speakers/SpeakersController.cs:48). Both speaker grids drive it with exactly this vocabulary: the organizer list sendsfilters["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).SpeakerEntityQueryServiceTestspins the contract, asserting that the captured pipeline parameters carry theFullNamekey 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.
FullNameis filterable and sortable but not requestable as a shaped field. Thefieldsparameter is validated through the overload that takes no map (MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:264, resolving toQueryFieldService.cs:317-318), so?fields=FullNamenever 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).PropertyMapis built with the default (ordinal, case-sensitive) comparer (:28), while the base's empty default usesStringComparer.OrdinalIgnoreCase(MMCA.Common/Source/Core/MMCA.Common.Application/Services/EntityQueryService.cs:101). Only the exact keyFullNamehits 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 inResolveSortExpressionresolves withBindingFlags.IgnoreCase(:197-201), so the request passes validation and Dynamic LINQ receives the bare, EF-ignoredFullNameinstead 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
Speakeraggregate. It declares how to hydrate the two child collections the read path may not be able to materialize through.Include(),SpeakerCategoryItemsandSpeakerQuestionAnswers(MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:7-10). Like its siblings, the class body is empty (:30-31).Depends on:
DeclarativeNavigationPopulator<TEntity>closed overSpeaker(:13);ChildNavigationDescriptor<TEntity, TParentId, TChild, TChildId>(:15,:22);IUnitOfWork, forwarded straight to the base (:12-13); and theSpeaker,SpeakerCategoryItem, andSpeakerQuestionAnswerentities.Concept reinforced, the collection direction: the mechanism is taught in Group 11 and, for the reference direction, by
SpeakerCategoryItemNavigationPopulatorabove (ADR-002). AChildNavigationDescriptorinverts 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 declaresRequiresChildren => true(ChildNavigationDescriptor.cs:25), so both descriptors here are gated on the caller'sincludeChildrenargument 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.Idagainst the child'sSpeakerId, 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-21nameof(Speaker.SpeakerCategoryItems),e => e.Id,child => child.SpeakerId,e.SetSpeakerCategoryItems(categoryItems)ChildNavigationDescriptor<Speaker, SpeakerIdentifierType, SpeakerQuestionAnswer, SpeakerQuestionAnswerIdentifierType>MMCA.ADC.Conference.Application/Speakers/SpeakerNavigationPopulator.cs:22-28nameof(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:
TParentIdisSpeakerIdentifierType(System.Guid) while bothTChildIdvalues areint(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.SpeakerIdis typedSpeakerIdentifierTypeon 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 ownintkey (ChildNavigationDescriptor.cs:45). - Both
AssignActiontargets go through the aggregate's own mutators,SetSpeakerCategoryItems(MMCA.ADC.Conference.Domain/Speakers/Speaker.cs:389-390) andSetSpeakerQuestionAnswers(:463-464), each a thin delegation to the framework'sSetItemsover the private backing list. Both areinternal, 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 asIReadOnlyCollection<>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.
PopulateAsyncreturns immediately when there are no entities or no unsupported includes, then loads only descriptors whosePropertyNameappears inUnsupportedIncludes(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 batchedWHERE childFK IN (...parentIds)query per descriptor viaNavigationLoader(MMCA.Common/Source/Core/MMCA.Common.Application/Services/NavigationLoader.cs:118), not one query per speaker.
- The generic quartet is worth reading closely, because the parent key and the child keys are different types here:
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 theSpeakerEntityQueryServiceregistration it is injected into (:74), and invoked by the read pipeline as theNavigationPopulator.PopulateAsyncdelegate 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 registerNullNavigationPopulator<TEntity>instead (MMCA.ADC.Conference.Application/DependencyInjection.cs:85).SpeakerNavigationPopulatorTestsasserts the type binds toINavigationPopulator<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.LinkedUserIdis 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 explicitincludesarray 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
SpeakerQuestionAnswerread as its own entity. One navigation: the parentSpeakerback-reference (MMCA.ADC.Conference.Application/Speakers/SpeakerQuestionAnswerNavigationPopulator.cs:7-10). - Depends on:
DeclarativeNavigationPopulator<TEntity>closed overSpeakerQuestionAnswer(:13);FKNavigationDescriptor<TEntity, TChild, TChildId>(:15);IUnitOfWork(:12); theSpeakeraggregate as the reference target. - Concept reinforced: none new. Structurally identical to
SpeakerCategoryItemNavigationPopulator, which teaches the FK direction: the same closed generic overSpeakerandSpeakerIdentifierType(:15),PropertyName = nameof(SpeakerQuestionAnswer.Speaker)(:17),ParentKeySelector = e => e.SpeakerId(:18, the get-only FK atMMCA.ADC.Conference.Domain/Speakers/SpeakerQuestionAnswer.cs:26),ChildForeignKeySelector = child => child.Id(:19),AssignActioncalling the public mutator withFirstOrDefault()(: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 bySpeakerQuestionAnswerNavigationPopulatorTests(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 onSpeakerNavigationPopulator, 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
Sponsoraggregate. One navigation, and it points up: theEventa sponsor belongs to (MMCA.ADC.Conference.Application/Sponsors/SponsorNavigationPopulator.cs:8-11). - Depends on:
DeclarativeNavigationPopulator<TEntity>closed overSponsor(:14);FKNavigationDescriptor<TEntity, TChild, TChildId>(:16);IUnitOfWork(:13); theEventaggregate as the reference target. - Concept reinforced: none new; see
SpeakerCategoryItemNavigationPopulatorfor 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 atMMCA.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:Sponsoris an aggregate root that owns no children of its own, which is why it needs exactly one descriptor and why its delete is the genericDeleteEntityHandler<TEntity, TIdentifierType>thatservices.AddEntityCrud<Sponsor, SponsorDTO, SponsorIdentifierType, SponsorCreateRequest, SponsorUpdateRequest>()registers wholesale (MMCA.ADC.Conference.Application/DependencyInjection.cs:145, resolving toMMCA.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 byDeleteEventHandlerwhen 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 genericEntityQueryService<TEntity, TEntityDTO, TIdentifierType>registration for sponsors (:91), which is injected intoSponsorsController(MMCA.ADC.Conference.API/Controllers/Sponsors/SponsorsController.cs:41). Covered bySponsorNavigationPopulatorTests(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Application.Tests/Sponsors/SponsorNavigationPopulatorTests.cs:16,:20,:24,:35). - Caveats / not-in-source:
SponsorDTOcarriesEventIdbut noEvent(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 Contracts • Index • ADC Conference - Infrastructure & Persistence ➡