Onboarding guide
19. ADC Conference - Infrastructure & Persistence
What this chapter covers. This is the adapter layer of the Conference module, the place where
the engine-agnostic domain meets concrete technology. Three concerns live here: (1) persistence
mapping, the 19 EF Core entity configurations that turn plain domain classes into SQL Server tables,
the abstract DbContext that declares the module's DbSets, and the seeder that puts the real
conference events and feedback questions into a fresh database; (2) outbound integration, the typed
HttpClient that talks to Sessionize (the conference's session-submission platform) and the AI
session scorer that runs one proposal through the framework's governed chat client, together with the
response guardrail that pipeline runs on every scoring answer; and (3) the DI wiring that registers
those services and composes the Conference AI guardrails, plus the one small output-cache adapter the
durable scoring pass calls back into. It is the per-module realization of Clean Architecture's ports
and adapters idea: the Application layer declares the ports
(ISessionizeService,
IAiScoringService,
ISessionScoresCacheEvictor), and this
Infrastructure layer supplies the adapters. [Rubric §3, Clean Architecture] assesses
whether dependencies point inward and the domain stays framework-free; here every EF, HTTP, and
AI concern is quarantined in Infrastructure, so the domain entities in
Group 17 carry no persistence or transport attribute at all.
Engine-agnostic entities, engine chosen by the config base class
The most important idea in this chapter is one the entities themselves never express: what storage
engine each entity uses is decided here, not in the domain. A Conference domain entity,
Session, Speaker,
Event, Sponsor,
Activity, Partner,
the join entities, is a plain class. The only
thing that binds it to SQL Server is which base class its configuration inherits from. All 19 configs in
this group (SessionConfiguration,
SpeakerConfiguration, EventConfiguration,
SponsorConfiguration, ActivityConfiguration,
PartnerConfiguration,
SessionAssetConfiguration, and the rest) derive from
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>
(for example MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionConfiguration.cs:12-13
and MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Activities/ActivityConfiguration.cs:11-12),
which is a thin shim carrying [UseDataSource(DataSource.SQLServer)]
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:16-17)
over the engine-neutral
EntityTypeConfiguration<TEntity, TIdentifierType>.
That attribute is what EntityDataSourceRegistry
reads to decide which physical database an entity belongs to. Swapping just that one base class would
re-point the same Session to Cosmos or SQLite with zero change to the domain, the application
handlers, or the entity: this is the per-entity half of the database-per-service strategy
(ADR-006,
ADR-018). [Rubric §8, Data Architecture] (deliberate persistence: transactions, migrations, soft-delete, audit, concurrency) is
the dominant lens for the whole persistence half of this chapter.
Each config inherits the cross-cutting behavior, then adds entity specifics
Every configuration's Configure method begins with base.Configure(builder) (for example
SessionConfiguration.cs:18, ActivityConfiguration.cs:17) and then adds its own mappings. That one
base call is where the framework injects the conventions applied uniformly: the strongly-typed key,
the table name and module schema, and the concurrency token, none of which any individual config
re-states. The per-entity bodies then declare what is unique: column lengths sourced from the domain's
invariant constants (SessionInvariants.TitleMaxLength at SessionConfiguration.cs:20-22,
EventInvariants.NameMaxLength at EventConfiguration.cs:20-22, SponsorInvariants.NameMaxLength at
SponsorConfiguration.cs:19-21, ActivityInvariants.NameMaxLength at ActivityConfiguration.cs:19-21,
PartnerInvariants.NameMaxLength at
PartnerConfiguration.cs:19-21),
required and optional flags, derived values resolved one way or the other (Speaker.FullName is left out
of the model with builder.Ignore(...) at SpeakerConfiguration.cs:68, while Session.Duration is
mapped as a stored computed column,
HasComputedColumnSql("DATEDIFF(minute, [StartsAt], [EndsAt])", stored: true) at
SessionConfiguration.cs:74-75, because the sessions grid sorts on it and an ORDER BY needs a real
column: dynamic LINQ cannot express a date difference and the provider does not translate DateTime
subtraction, so the value has to live in the database where no writer can set it to anything else,
SessionConfiguration.cs:66-73), value conversions
(Speaker.Email round-trips through
NullableEmailValueConverter at
SpeakerConfiguration.cs:42-45, and Sponsor.Tier is stored as its underlying int with
HasConversion<int>() so tier ordering is a plain column sort and adding a package later does not
rewrite existing rows, SponsorConfiguration.cs:23-27, and
PartnerConfiguration states the same reason for storing Partner.PartnerType
as an int, PartnerConfiguration.cs:23-27), and decimal precision (HasPrecision(3, 1) on
the overall AI score and all six sub-scores, SessionAiScoreConfiguration.cs:22-48, next to a
4000-character Reasoning, a 100-character ModelUsed and a 32-character PromptVersion,
SessionAiScoreConfiguration.cs:50-63, the last of which records which prompt contract produced the row).
A config also states how EF reaches an encapsulated collection: Event's navigations are getters over
private list fields, so EventConfiguration pins
UsePropertyAccessMode(PropertyAccessMode.Field) on Rooms, EventSpeakers and EventQuestionAnswers
(EventConfiguration.cs:96-101). Convention already infers field access there; stating it makes
materialization independent of that inference, and it is access mode only, no schema change
(EventConfiguration.cs:90-95).
Filtered indexes are where the soft-delete convention becomes visible, and the split is worth
learning because it is easy to misread. Unique indexes on a soft-deletable entity get the
IsDeleted = 0 predicate automatically, applied by
SoftDeleteUniqueIndexConvention
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/SoftDeleteUniqueIndexConvention.cs:44-55),
so a soft-deleted link never blocks a re-insert;
CategoryItemConfiguration relies on exactly that and declares its unique
(CategoryId, Name) index with no filter call at all (CategoryItemConfiguration.cs:30-31). A
hand-authored non-unique index is deliberately left alone by the convention and opts in explicitly
through
IndexBuilderExtensions.HasSoftDeleteFilter()
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/IndexBuilderExtensions.cs:20-29),
which replaces the old literal HasFilter("[IsDeleted] = 0") by reading the column name from the model
and the quoting from the engine. Eight lookup indexes here take that opt-in: Session.EventId
(SessionConfiguration.cs:85-86), Sponsor.EventId (SponsorConfiguration.cs:67-68),
Partner.EventId, the only index PartnerConfiguration declares, over the
required per-event foreign key it maps just above (PartnerConfiguration.cs:44-53),
EventQuestionAnswer.EventId (EventQuestionAnswerConfiguration.cs:35-36), both of
ActivityConfiguration's, the plain EventId lookup
(ActivityConfiguration.cs:58-59) and the composite (EventId, StartTime, SortOrder) that serves the
public activities page's ordering directly instead of sorting an event slice in memory
(ActivityConfiguration.cs:61-64), and both of
SessionAssetConfiguration's, the composite (SessionId, SortOrder) that
serves the only read shape the table has, one session's assets in sort order
(SessionAssetConfiguration.cs:70-74), and the EventId lookup the event-level cascade delete uses to
select every asset of one event in a single read (SessionAssetConfiguration.cs:76-78). Several unique
indexes also call it explicitly for readability even
though the convention would supply it: SessionSpeakerConfiguration's
(SessionId, SpeakerId) pair (SessionSpeakerConfiguration.cs:30-32), the one-score-per-session index on
SessionAiScoreConfiguration (SessionAiScoreConfiguration.cs:66-68),
the equivalent pairs on EventSpeakerConfiguration
(EventSpeakerConfiguration.cs:30-32),
SessionCategoryItemConfiguration
(SessionCategoryItemConfiguration.cs:30-32) and
SpeakerCategoryItemConfiguration
(SpeakerCategoryItemConfiguration.cs:30-32), the per-event room name on
RoomConfiguration (RoomConfiguration.cs:52-54), and the one-answer-per-user
(entity, question, creator) triples on
SessionQuestionAnswerConfiguration
(SessionQuestionAnswerConfiguration.cs:43-45) and
EventQuestionAnswerConfiguration
(EventQuestionAnswerConfiguration.cs:42-44).
Two indexes are deliberately unfiltered, and both carry a comment explaining why, because in each
case the filtered composite next to them is not a substitute. RoomConfiguration re-declares the
conventional foreign-key index on EventId (RoomConfiguration.cs:46-48) because EF drops it as
redundant once the composite (EventId, Name) index leads with the same column, while the foreign-key
lookups still want it. SessionQuestionAnswerConfiguration keeps its plain SessionId index
(SessionQuestionAnswerConfiguration.cs:34-37) because the Sessionize sync reads that table by
SessionId with the global query filters off, and a filtered index cannot serve a query that does
not carry the predicate. Sparse filters are a different thing again and stay literal, because they
filter on a nullable business column rather than on soft-delete: Speaker.LinkedUserId is unique only
where it is set (SpeakerConfiguration.cs:63-65, the User-to-Speaker link), and Event.SessionizeCode
is indexed only where present (EventConfiguration.cs:45-47). Two further quirks are worth knowing:
ConferenceCategoryConfiguration calls
ToTable("Category", "Conference") explicitly (ConferenceCategoryConfiguration.cs:22-24) so the
Conference Category table cannot collide with another module's Category, and
SessionConfiguration maps the Session-to-Room relationship with
OnDelete(DeleteBehavior.Restrict) (SessionConfiguration.cs:91-95) so deleting a room can never
cascade sessions away. SessionAssetConfiguration shows the opposite end
of the modelling choice: both of its foreign keys are declared with HasOne<Event>() and
HasOne<Session>() and no navigation property at all
(SessionAssetConfiguration.cs:60-68), so the database gets its referential integrity while the model
gains no traversal path the read side never uses (SessionAssetConfiguration.cs:13-17). Two configs
declare no index at all and map columns only,
QuestionConfiguration (QuestionConfiguration.cs:10) and
SpeakerQuestionAnswerConfiguration
(SpeakerQuestionAnswerConfiguration.cs:10).
DbSets, the context shape, and how the configurations are actually found
ModuleApplicationDbContext
(MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:22) is the
Conference module's abstract DbContext. It does one job: declare 17 internal DbSet<T> properties
(Events, Rooms, EventSpeakers, EventQuestionAnswers, Sessions, SessionSpeakers,
SessionQuestionAnswers, SessionCategoryItems, Speakers, SpeakerCategoryItems, Categories,
CategoryItems, Questions, Sponsors, Partners, Activities, SessionAssets, at
ModuleApplicationDbContext.cs:30-78). It is abstract and inherits from the Common
ApplicationDbContext through its primary
constructor (ModuleApplicationDbContext.cs:22-27), from which it gets the real machinery: the
SaveChangesAsync override that stamps audit fields and captures domain events into the outbox, and the
global soft-delete query filters applied to every auditable entity. The concrete class EF actually
instantiates is the single SQLServerDbContext in
the Common framework: one concrete context class per engine, one instance per database
(ADR-006). The codebase deliberately
does not split into per-module context classes.
A detail that surprises most readers: a DbSet is not what puts an entity in the model. The base
context walks the registered configuration assemblies and applies every
IEntityTypeConfigurationSQLServer<,> implementation whose entity resolves to this context's data
source key
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:827-857,
with the engine-to-interface switch at :612-618 and the registry filter at :625-636). That is why two
entities with a configuration here, SessionAiScore and
SpeakerQuestionAnswer, are mapped and queryable
through the repository layer even though ModuleApplicationDbContext declares no DbSet for either:
19 configurations, 17 DbSets, and the configurations win. [Rubric §7, Microservices Readiness] (can a
module become its own service without a rewrite?) is embodied here: the Conference module already runs as
MMCA.ADC.Conference.Service over its own ADC_Conference database
(MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:32) with its own outbox, and cross-module
references (a speaker's linked user, a bookmark's session) are scalar columns resolved via gRPC and
integration events, never cross-database foreign keys.
Seeding: two real events always, sample data only in dev and CI
ConferenceModuleDbSeeder
(MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:35)
derives from the framework's DbSeeder and runs after schema
initialization, constructed by ConferenceModuleSeeder
in the API layer (MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:28). It is idempotent: every step
first issues an ExistsAsync check through the repository and returns early if the row is present
(ConferenceModuleDbSeeder.cs:79, :114, :149), which is what makes it safe to run on every
startup under the production Migrate init strategy
(ADR-030). It always seeds three
things (ConferenceModuleDbSeeder.cs:55-57): the 2026 Atlanta Cloud + AI Conference (2026-05-30,
America/New_York, Sessionize code z1ecmzux, ConferenceModuleDbSeeder.cs:89-94), the 2026 Atlanta
Developers Conference (2026-10-17, Sessionize code sf1nopko, ConferenceModuleDbSeeder.cs:124-129),
both published immediately after creation (:104 and :139) and both carrying the shared venue address,
map URL and their own published sponsorship-packet URL (ConferenceModuleDbSeeder.cs:29-47), and the
fixed set of 10 feedback questions (5 session ratings plus a session comment, 3 conference ratings
plus a conference comment, ConferenceModuleDbSeeder.cs:157-169) whose ids start at
QuestionInvariants.ManualIdRangeStart
(ConferenceModuleDbSeeder.cs:171) so they never collide with imported data. The Cloud + AI existence
check matches the pre-rename seed name as well as the current one, so a database seeded before the event
was renamed still resolves and is not seeded twice (:78-80).
It conditionally seeds six more things (ConferenceModuleDbSeeder.cs:59-67): two sample speakers
(Ada Lovelace and Alan Turing, :199-200), two sample sessions with app-assigned ids from
SessionInvariants.ManualIdRangeStart, one per
seeded event (:257-258, and the ids are explicit because a Session's int PK is its Sessionize id, so
the sample rows take a reserved range above any real one), the EventSpeaker plus
SessionSpeaker links between them (:325-326), four sample sponsors across the Platinum, Gold, Silver
and Community tiers, two of them exhibitors with booth numbers (:364-369), four sample
Partner rows, three on the Developers edition (two
Community and one Special) and one on the Cloud + AI edition, so the landing-page partner band
renders fully in dev and CI and the organizer list has a row whichever event its filter defaults to
(:416-418, :424-429), and three sample social
activities (a pre-conference party the evening before the Developers Conference, a morning coffee
connect, and an after-party) whose event-local wall-clock times are anchored on each event's own start
date (:478-479, :481-496). All of that runs only when includeSampleData is set. The flag comes from
Seeding:IncludeSampleConferenceData (MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:26), which the
local Aspire AppHost sets (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:158) and production
leaves unset. The reason is documented in the seeder's own remarks
(ConferenceModuleDbSeeder.cs:14-26): the public-browse E2E tests need at least one session and one
speaker row to exist deterministically, while production's real sessions and speakers arrive through the
Sessionize import. The links are created on both paths deliberately, so the direct (EventSpeaker) and
the transitive (SessionSpeaker) branches of the speakers-by-event filter are both exercised in dev and CI
(ConferenceModuleDbSeeder.cs:322-324).
The Sessionize adapter
SessionizeService
(MMCA.ADC.Conference.Infrastructure/Events/Sessionize/SessionizeService.cs:12) is a deliberately thin HTTP
client: the whole class is one method (SessionizeService.cs:15). Given a Sessionize event code it
builds the relative URI {code}/view/All (SessionizeService.cs:32), calls GetAsync, asserts success
with EnsureSuccessStatusCode (SessionizeService.cs:37), and deserializes into the
SessionizeResponse model owned by the
Application layer (SessionizeService.cs:39-41). Unlike the AI adapter it does throw on a bad
status, because the import use-case that calls it is a foreground operation with a caller waiting on the
result. One thing it does not throw on is a malformed event code: the method re-checks the code
against SessionizeCodeFormat.IsValid and returns an Error.Invariant failure
Result instead of calling out
(SessionizeService.cs:23-30). The comment above it states the threat in full
(SessionizeService.cs:17-22): the code becomes the leading segment of a relative URI resolved against
the configured base address, and RFC 3986 reads //host/path as a network-path reference, so an
unchecked code would redirect the request to a foreign host whose JSON is then imported as speakers,
sessions and rooms. The request validators already enforce the charset at the boundary, and this is the
second layer that also covers a code written to the database before that rule existed: [Rubric §11, Security] defence in depth on an outbound call. It is registered as a typed HttpClient in
DependencyInjection with the base address https://sessionize.com/api/v2/
baked in (MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:32-34), so it inherits the standard Aspire
resilience handler (Polly retry, timeout, circuit breaker) unchanged: [Rubric §29, Resilience & Business Continuity], the ADR-009
policy that every outbound client gets resilience by default. The thinness is intentional: parsing,
mapping, and the import workflow live in Application use-cases, and this adapter owns only the wire call.
The AI scoring adapter and its response guardrail
SessionScoringService
(MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringService.cs:47) is the richer of the
two adapters: it scores one session proposal against a Program Committee rubric. It owns no transport at
all and names no vendor. It takes an IChatClient (SessionScoringService.cs:47), a
PromptContract (:48) and a logger (:49), and the
model, the output ceiling, the per-call timeout and the tool gate are configuration under the Ai section
handled by the framework's governed chat client in MMCA.Common.AI (SessionScoringService.cs:12-17,
ADR-111). What stays here is
the part only this module can know: the prompt, the delimited envelope, the output schema and the scoring
arithmetic. Two duties that used to live in this class have left it for the governed pipeline, and the
remarks record both: contact-detail redaction is the framework's
PiiRedactionGuardrail, so it is a policy the
feature cannot bypass rather than a step this service has to remember (:29-35), and validating the
answer is SessionScoreResponseGuardrail's (:36-44). It implements
IAiScoringService (:49), and one scoring call
is ToChatOptions() off the contract plus two request-specific settings, a 256-token output ceiling
(:70, :115) that Ai:MaxOutputTokens clamps again at the framework boundary (:66-68), and
ChatResponseFormat.ForJsonSchema(ScoreSchema) (:116), then a single GetResponseAsync with the user
message (:118-121).
The chat client is nullable on purpose, and that is the whole disabled path: it is resolved with
GetService rather than GetRequiredService, because the framework registers no client when Ai:Enabled
is false, so a host with no AI configured still starts and still serves every other Conference endpoint
while scoring answers a failed result and calls nothing (SessionScoringService.cs:21-26, :102-106, and
the registration comment at DependencyInjection.cs:42-46). The rest of the contract is equally precise
about failure: it never throws for a scoring failure, but cancellation propagates (:18-19). A
ChatGuardrailException raised by a registered
guardrail is caught on its own so the log line carries the reason the guardrail reported (:125-133),
every other failure (no client, unparseable JSON, a partial score object, any other exception) funnels into
FailedResult, which returns zero scores with Success = false (:344-357), and the catch filter
when (ex is not OperationCanceledException) (:134) lets host shutdown unwind. That split matters because
scoring runs a whole event: one bad proposal must not abort the pass, but a deploy must still be able to
stop it.
The prompt is a versioned contract, not a string constant, and that is what makes a score reproducible.
SessionScoringContract is a static PromptContract built from the prompt name session-scoring (:56),
the dated version 2026-09-04.1 (:219), the pinned model claude-haiku-4-5 (:63) and the system brief
(:223-249), assembled at :92-93, and the service reports ModelId and PromptVersion by reading them
back off that contract so the two can never disagree (:73, :84, :216-218). The framework hashes the
contract and stamps the hash on every request, which is what the golden evaluation gate keys on; the
remarks state the rule that any edit to the system prompt, the user-prompt builder, the speaker formatting
or the schema must bump the version, so an unversioned edit fails a test instead of quietly re-basing scores
already on the dashboard (:76-83). RenderPrompt exposes the exact prompt pair without calling a model
(:325-332) so that suite can hash it offline.
The output is schema-constrained rather than parsed out of prose. BuildScoreSchema() emits a JSON
Schema naming the six criteria as numbers, a penalty restricted to 0, 0.5 or 1, and a reasoning
string, with additionalProperties = false and every field required
(SessionScoringService.cs:362-393); it is built once into the static ScoreSchema (:360) and rides the
request's response format (:116). The wire shape now has a file of its own:
AiScoreResponse
(MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AiScoreResponse.cs:22) is an internal sealed record
whose six sub-scores and penalty are nullable so a partial object is detectable rather than silently
defaulting to zero (AiScoreResponse.cs:28-57), and it carries the one JsonOptions instance (:25) and
an IsComplete check (:63-70). It lives beside the scorer rather than inside it because two readers need
it, and one declaration means the guardrail cannot drift from what the scorer would accept
(AiScoreResponse.cs:14-20). The scorer's own share is a single JsonSerializer.Deserialize<AiScoreResponse>
call (SessionScoringService.cs:164), a property pattern that fails a partial object (:177-189), and the
documented weighted sum (topic 30%, description 10%, novelty 20%, takeaways 20%, depth 10%, credibility
10%) minus the penalty (:191-204), with every value clamped to [1.0, 10.0] and rounded to one decimal
with banker's rounding (:342). Its JsonException catch is kept as the fail-closed path for a caller that
composes the scorer without the governed pipeline, as the golden replay suite does (:159-170). The
Application layer sees only
SessionScoringResult.
SessionScoreResponseGuardrail
(MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoreResponseGuardrail.cs:34) is the response
half of that boundary, an IChatGuardrail the governed
pipeline runs on every call. It moved out of the scorer for the reason the redaction did: a rule a feature
applies to itself is a rule a second call site can forget, while a registered guardrail cannot be bypassed
by a caller (SessionScoreResponseGuardrail.cs:15-21). InspectResponseAsync (:74-82) answers a
GuardrailVerdict that blocks three shapes, each
with a public reason constant: a refusal, detected from both the normalized ChatFinishReason.ContentFilter
and the provider's raw refusal value so a mapping change on either side cannot turn a refusal into an
unparseable answer (:94-100, :125-134, reason :40-41), an empty answer (:102-106, :44-45), and
text that does not deserialize to an AiScoreResponse or deserializes without IsComplete
(:108-122, :51-52). It is scoped to the session-scoring prompt by reading the name off the
request's prompt contract and comparing it to SessionScoringService.PromptName (:62-63), because "the
answer must be a score object" is false of every other AI call a host might add later, and an unscoped rule
would block the first one (:23-26). The request side and streamed updates always allow (:66-71,
:84-90): it has nothing to say about what goes out, and a per-fragment JSON rule would refuse every
well-formed answer before it finished arriving (:28-31).
The prompt itself is treated as an attack surface, which is the [Rubric §11, Security] story here
alongside the obvious one (the credential is configuration the framework reads, never a literal in this
file), and it is also where [Rubric §16, AI-Native] is most concrete. Everything a speaker typed arrives
through a public call-for-papers form, so the user message is a delimited envelope (<session_proposal>
with <session_title>, <session_description> and a <speakers> block,
SessionScoringService.cs:278-315) rather than labelled lines, because labelled lines gave a submission no
boundary to be contained by (:273-277). Angle brackets in every submitted value are escaped so a
submission cannot forge a delimiter, a typed </session_title> arriving as </session_title>
(:334-340). Speaker names are deliberately left intact because they are the published conference record
and the only evidence the credibility criterion has (:300-303); emails and phone numbers are stripped by
the framework guardrail on the assembled message, and a proposal's instruction-override phrase is
neutralized before the call by the framework's
ContentPolicyGuardrail under
Ai:ContentPolicy (DependencyInjection.cs:90-96). The system brief carries a matching
UntrustedInputBrief constant (SessionScoringService.cs:264-271) that declares the tagged content data
rather than instructions and routes an injection attempt to the existing 1.0 penalty, which gives the model
a rule to apply instead of a judgement call to make (:256-261).
[Rubric §13, Observability & Operability] and [Rubric §31, Cost/FinOps] meet in the usage path, and
the split of duties there is worth reading. The aggregate is the framework's: the governed pipeline's
usage-recording chat client reports mmca.ai.input_tokens and mmca.ai.output_tokens on the
MMCA.Common.AI meter, tagged by model, prompt name, prompt version and provider, which is why the prompt
contract's identity is a first-class value here rather than a log string
(SessionScoringService.cs:143-145, :51-56). What this adapter keeps is per-session forensics: when the
response carries a usage block it logs the input and output token counts against the session id
(:146-149). Two [LoggerMessage] source-generated methods carry the log half, a warning naming the
session id and the failure reason, and that information line (:395-399).
The scoring run is durable now, and this layer keeps one adapter inside it
A multi-minute paid AI pass triggered from an HTTP POST used to be this chapter's biggest piece of
machinery: a hosted BackgroundService draining an in-memory queue, plus a five-minute cron sweep that
re-queued whatever a crash had cut in half. Both are gone from this assembly, and the registration comment
says why in one line: the framework's internal-command processor owns the durability, the claim lease and
the retry backoff now (DependencyInjection.cs:52-56,
ADR-114). The organizer's
POST schedules a ScoreEventSessionsInternalCommand through IInternalCommandScheduler and returns
202 Accepted
(MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:40,148-150,160), the row is
persisted, and
ScoreEventSessionsInternalCommandHandler
in the Application layer runs the pass. Cross-replica exclusion did not disappear with the drain, it moved:
the handler takes an IDistributedLock claim on the event
with a 15-minute time-to-live and a zero wait, so the loser of a duplicate trigger logs and succeeds
instead of paying for the same pass twice
(MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsInternalCommandHandler.cs:14-21,61,68).
[Rubric §29, Resilience & Business Continuity] reads better for it: durability is one framework
capability rather than a per-module queue plus a per-module sweep.
What this layer keeps is the one step the Application layer cannot take.
OutputCacheSessionScoresCacheEvictor
(MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/OutputCacheSessionScoresCacheEvictor.cs:17)
implements the Application-owned port
ISessionScoresCacheEvictor over the
host's IOutputCacheStore, because an internal command handled in Application cannot see ASP.NET's output
cache (DependencyInjection.cs:52-54). Its whole body is one call, and the value is in which tag it
evicts: the narrow conference:sessions rather than the root conference tag every Conference policy
carries (OutputCacheSessionScoresCacheEvictor.cs:20,23-24). The remarks record the production reason
(:9-15): scoring writes session scores and nothing else, and evicting the root flushed events, speakers,
rooms, categories and questions too, so an organizer triggering a scoring run during the event emptied the
whole public read surface onto the Basic-tier database twice while attendees were browsing. [Rubric §12, Performance & Scalability] and [Rubric §31, Cost/FinOps] both live in that one constant
(ADR-026,
ADR-040).
DI wiring, and what it deliberately no longer registers
DependencyInjection
(MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:18) is a single
extension(IServiceCollection) block (the codebase's standard DI-registration idiom, taught in the
primer, DependencyInjection.cs:20-133) exposing three methods. The module method,
AddModuleConferenceInfrastructure() (DependencyInjection.cs:26-60), registers exactly three things:
the Sessionize typed HttpClient with its base address baked in (:32-34), which takes Aspire's standard
resilience handler unchanged
(ADR-009); the scoring
adapter, constructed by hand as a scoped IAiScoringService so it can be handed
GetService<IChatClient>() and the static SessionScoringContract (:47-50); and the output-cache
evictor as a scoped ISessionScoresCacheEvictor (:57). Both adapters use TryAdd, so a host or a test
can substitute either before this call runs.
The other two methods exist because of an ordering rule the module method cannot honor.
AddConferenceAiGuardrails(IConfiguration) (DependencyInjection.cs:85-102) is the single composition
point for every guardrail the Conference AI pipeline runs: the framework's PiiRedactionGuardrail and
ContentPolicyGuardrail on the request side (:95-96) and this module's response guardrail (:99).
The remarks give the reason it exists: when each caller assembled its own list, the golden replay suite
drove the scorer bare and the live judge registered one of the three, so the evaluation tiers were judging
a different pipeline from the deployed one; one method shared by the host and every tier means a new
guardrail reaches the gates automatically (:70-76). Registering either request-side guardrail also
satisfies Ai:RequireGuardrail, which defaults to true (:93-94). It must run before
AddMmcaChatClient, which reads the IChatGuardrail descriptors to decide whether to compose the
guardrail layer at all, so a later registration would be silently absent (:78-82), and the Conference
service host calls the two in exactly that order
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:152,154).
AddSessionScoreResponseGuardrail() (DependencyInjection.cs:124-132) registers the one policy with
TryAddEnumerable as a singleton IChatGuardrail (:128-129), so it stacks with the framework's
guardrails instead of replacing them and a second call composes nothing twice (:111-115); it stays public
for a caller that wants only the response gate (:117-122).
The three registrations that are absent teach as much as the ones that are present, and each left a
comment behind. The provider error classifiers this module used to own (a unique-constraint detector and a
concurrency-conflict detector) are framework surface now, registered by MMCA.Common's AddInfrastructure
(DependencyInjection.cs:28-31). No HttpClient is constructed for the AI provider and no vendor is
named: the transport, the credential, the model, the output ceiling, the per-call timeout and the tool gate
belong to the governed IChatClient configured under the Ai section and registered by the host's
AddMmcaChatClient call over whichever provider adapter Ai:Provider names (:36-40). And the hosted
drain plus the five-minute crash-recovery sweep are gone entirely (:52-56). That is the shape of a
healthy module wiring file: every line in it is something only this module can know.
How it fits together at runtime
Three flows tie the chapter together. Persistence flow: a Conference command handler mutates an
aggregate and the unit of work saves; that resolves the concrete SQLServerDbContext over the
ADC_Conference database, whose model was built from the configurations registered here (lengths,
indexes, relationships, precision), stamps audit fields, hides deleted rows behind the global filters,
and captures domain events into the per-database outbox, all in one transaction. Import flow: an
organizer triggers a Sessionize refresh; the Application use-case calls
ISessionizeService, the typed HttpClient
adapter makes the outbound call inside the default Polly pipeline, and the parsed SessionizeResponse
flows back for mapping. Scoring flow: the organizer POSTs to the scoring endpoint, the controller only
schedules a ScoreEventSessionsInternalCommand and returns 202 Accepted
(MMCA.ADC.Conference.API/Controllers/Sessions/SessionSelectionController.cs:148-150,160), the framework's
internal-command processor picks the persisted row up on one of the replicas, and the Application handler
evicts the sessions cache tag through
OutputCacheSessionScoresCacheEvictor, claims the event's
distributed lock, calls SessionScoringService once per session through the
governed chat client (whose request-side guardrails strip contact details and neutralize an
instruction-override phrase, and whose SessionScoreResponseGuardrail
refuses any answer that is not a complete score object), persists one SessionAiScore row per session behind the unique filtered index, and
evicts the tag again; if that run dies mid-pass, the command row is still there and the processor replays
it under its own backoff and attempt ceiling. The two marker types in this assembly,
AssemblyReference and ClassReference
(MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:5 and
MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:11), exist purely so the module loader and the
configuration-assembly scan can reach this assembly by a stable typeof() handle instead of a hard-coded
type list, the same extension point every module assembly provides.
AssemblyReference
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:5· Level 0 · class (static)
- What it is: the assembly-marker type for the Conference Infrastructure assembly: a stable handle that reflection-based scanning can hold instead of a hard-coded assembly name string.
- Depends on:
System.Reflectiononly. No first-party types. - Concept: cross-reference the framework explanation under AssemblyReference in the Conference Domain chapter; every layer of every module ships an identical pair, and the module system chapter teaches why.
- Walkthrough: two
public static readonlyfields (AssemblyReference.cs:7-8):Assembly = typeof(AssemblyReference).Assembly, andAssemblyName= its simple name with a?? string.Emptyfallback so the field is never null. - Why it's built this way: assembly scanning (EF
IEntityTypeConfigurationdiscovery, handler and mapper registration) and the module loader all need a per-assembly token; takingtypeof(AssemblyReference).Assemblysurvives renames and trimming better than a literal name. - Where it's used: Conference module registration and EF configuration discovery scan this assembly through this marker, which is how the sixteen configuration classes in this chapter are found without being listed anywhere.
ClassReference
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/AssemblyReference.cs:11· Level 0 · class
- What it is: an empty, non-static class whose only purpose is to be a
typeof(...)or generic anchor for registration APIs that take a type rather than anAssembly. - Depends on: nothing.
- Concept: cross-reference ClassReference where the pattern is introduced.
- Walkthrough: the whole declaration is one line,
public class ClassReference { }(AssemblyReference.cs:11). It is deliberately non-static (unlike its sibling above) because a static class cannot be used as a generic type argument. - Why it's built this way: some registration helpers are shaped as
Add...(typeof(T))orAdd...<T>(); an empty public class gives those calls a target without exposing a real implementation type. - Where it's used: assembly-scanning registration call sites in the Conference module composition path (see Conference API, gRPC contracts and service host).
AiScoreResponse
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Sessions.Scoring·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/AiScoreResponse.cs:22· Level 0 · record (internal sealed)
- What it is: the structured-output object a session-scoring call asks the model for, in the shape SessionScoringService's score schema declares. Six weighted sub-scores, a penalty, and a free-text
reasoningline. - Depends on: no first-party types (its two readers depend on it, not the other way around). External:
System.Text.Json.Serialization.JsonPropertyName,System.Text.Json.JsonSerializerOptions. - Concept introduced, one declaration shared by two readers. The type used to be a private record nested inside the now-removed
AnthropicScoringService; it is now its own top-levelinternal sealed recordin its own file, and its XML remarks (:50-56) say why: two readers need it, SessionScoringService, which maps a complete object onto a SessionScoringResult, and SessionScoreResponseGuardrail, which refuses an incomplete one at the pipeline boundary. One declaration of the shape means the guardrail cannot drift from what the scorer would accept, and the JSON schema is still declared exactly once, on the scorer.[Rubric §3, Clean Architecture]assesses whether external contracts stay out of inner layers: the Application layer only ever seesSessionScoringResult, never the model's wire shape.[Rubric §32, Dependency & Supply-Chain]assesses how a third-party API dependency is isolated: if the model's envelope changes shape, only this one file and its readers change. - Walkthrough: a
staticJsonOptionsproperty (:61,PropertyNameCaseInsensitive = true) both readers deserialize with. Eightinitproperties (:64-93), each carrying an explicit[JsonPropertyName].Penalty(:65) and the six criterion scores (TopicRelevance:69,DescriptionQuality:73,Novelty:77,ActionableTakeaways:81,DepthOrInsightQuality:85,CredibilityExperience:89) aredecimal?, notdecimal: nullability is what makes a partial model response detectable.Reasoning(:93) staysstring?, the only genuinely optional field.IsComplete(:99-106) is a computedinternal bool:trueonly when the penalty and all six sub-scores are non-null; reasoning is deliberately not required, matching the scorer's own acceptance rule. - Why it's built this way: making the score fields nullable turns "the model returned four of six scores" into a detectable parse failure rather than a plausible-looking but wrong row in SessionAiScore; computing
IsCompleteonce on the record itself, rather than re-deriving the same null-check twice, is what lets the scorer and the guardrail agree on "complete" without sharing any other code. - Where it's used: SessionScoringService
.ParseSingleScore(:304) deserializes into it and builds aSessionScoringResultfrom it; SessionScoreResponseGuardrail.Inspect(:585) deserializes into it and readsIsCompleteto decide whether to allow or block the response.
[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).
OutputCacheSessionScoresCacheEvictor
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Sessions.Scoring·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/OutputCacheSessionScoresCacheEvictor.cs:17· Level 1 · class (sealed)
- What it is: the Infrastructure adapter for ISessionScoresCacheEvictor, evicting the output-cache tag that fronts session score reads after a scoring pass writes fresh scores.
- Depends on: first-party: ISessionScoresCacheEvictor (implements). External:
Microsoft.AspNetCore.OutputCaching.IOutputCacheStore. - Concept: the same dependency-inversion shape taught under SessionScoringService and SessionizeService: Application declares the port, Infrastructure supplies the concrete ASP.NET Core dependency. The DI registration comment (
DependencyInjection.cs:52-56) is explicit about why the port exists at all: the internal-command handler that runs a scoring pass lives in the Application layer, which cannot seeIOutputCacheStoredirectly. - Walkthrough: a primary constructor injects
IOutputCacheStore(:17).SessionsCacheTag(:20) is the private constant"conference:sessions", the tag Conference's session-read output-cache policies are registered under.EvictAsync(:23-24) is a one-line expression body that callsoutputCacheStore.EvictByTagAsync(SessionsCacheTag, cancellationToken)and returns theValueTaskdirectly, noawaitneeded. - Why it's built this way: a single-method adapter keeps the output-cache API entirely out of Application, matching the boundary every other Infrastructure adapter in this chapter draws.
- Where it's used: registered by DependencyInjection (
services.TryAddScoped<ISessionScoresCacheEvictor, OutputCacheSessionScoresCacheEvictor>()); called by the scoring pass's internal-command handler once a batch has written newSessionAiScorerows, so a cached session-score read never outlives the scores it summarizes. Exercised directly byOutputCacheSessionScoresCacheEvictorTests.
SessionScoringService
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Sessions.Scoring·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringService.cs:47· Level 3 · class (sealed partial)
- What it is: the replacement for the removed
AnthropicScoringService. It implements IAiScoringService by scoring one session proposal against a Program Committee rubric through the framework's governedIChatClient(MMCA.Common.AI), rather than by calling a named vendor's HTTP API directly. The governance around it (prompt versioning, the golden evaluation suite, the injection posture) is recorded in ADR-111; the boundary that put the chat client itself behind a framework port is ADR-120. - Depends on: first-party: IAiScoringService (implements), SessionScoringInput, SessionScoringResult, SpeakerInfo, PromptContract (constructor parameter and the source of
PromptVersion), and AiScoreResponse (own file now, not a nested record). External:Microsoft.Extensions.AI(IChatClient,ChatMessage,ChatResponse,ChatOptions,ChatGuardrailException),ILogger<T>,System.Text.Json,System.Globalization. - Concept introduced, the module supplies only the prompt, the framework supplies the vendor.
[Rubric §3, Clean Architecture]and[Rubric §1, SOLID](dependency inversion) assess whether inner layers depend on abstractions rather than vendors: this class constructs noHttpClientand names no vendor anywhere in its own code, it depends onIChatClient, the framework's own port.[Rubric §16, AI-Native Application Architecture]assesses whether model interaction is versioned, constrained and evaluable:PromptVersion(:85), theSessionScoringContractstatic (:93-94) andPromptVersionValue(:229,"2026-09-04.1", formatyyyy-MM-dd.N) are the versioning story; the golden evaluation suite pins the rendered prompt by hash per version.[Rubric §29, Resilience & Business Continuity]assesses graceful degradation: an unconfigured client (:103-107), a guardrail block (:126-134), a governed-timeout cancellation the caller did not request (:135-143), and any other non-cancellation exception (:144-148) all converge onFailedResult, so one bad or slow proposal cannot abort a batch. - Walkthrough
- A primary constructor injects
IChatClient? chatClient,PromptContract promptContractandILogger<SessionScoringService>(:47-50).IChatClientis resolved withGetService, notGetRequiredService, by DependencyInjection (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:47-50), so a host with AI disabled or no key still starts. PromptName(:57,"session-scoring") tags every usage measurement on theMMCA.Common.AImeter.ModelIdValue(:64,"claude-haiku-4-5") is pinned into the prompt contract's hash.MaxOutputTokens(:71,256) is the class's own default thatAi:MaxOutputTokensclamps again at the framework boundary.SessionScoringContract(:93-94) is the one static PromptContract the module scores with, built from those constants plusSystemPrompt; composition passes it to the constructor so a test can score against a different contract without touching the registration.ScoreSessionAsync(:97) null-checkssession(:101), then guards on a disabled client (:103-107, log-and-FailedResult), builds the user message withBuildUserPrompt(:111), then asks the contract for itsChatOptions(:115) and layers onMaxOutputTokens(:116) and a JSON-schemaResponseFormatbuilt fromScoreSchema(:117).chatClient.GetResponseAsyncsends one userChatMessage(:119-122). Three catch clauses follow, in order. A caughtChatGuardrailException(:126-134) logs the guardrail's own reported reason and returnsFailedResult. AnOperationCanceledExceptionwhose filter is!cancellationToken.IsCancellationRequested(:135-143) is the governed client's ownAi:Timeoutcancellation surfacing as a cancellation the caller never asked for: it is logged as a per-call timeout and returnsFailedResultfor that one session, so a slow provider fails one score rather than the whole pass. Any other exception that is not anOperationCanceledExceptiondoes the same (:144-148), which means only the caller's own cancellation propagates.InterpretResponse(:151) logs usage fromresponse.Usagewhen present (:156-159), then handsresponse.Textstraight toParseSingleScore(:164): refusals, empty answers and malformed text are already refused upstream by SessionScoreResponseGuardrail, so this method's own job is parse and map.ParseSingleScore(:167-181) deserializesresponseTextinto AiScoreResponse withAiScoreResponse.JsonOptions(:174) and passes it toBuildResult; aJsonExceptionis the fail-closed path for a caller that composed the scorer without the governed pipeline, that is, a direct construction over a bare chat client (:169-172,:177-179).BuildResult(:183) requires the penalty and all six sub-scores present via a singleis not { ... }pattern (:187-196); the overall score is weighted 30/10/20/20/10/10 (:201-209) and the penalty subtracted (:214), with every value passed throughClamp(:352),Math.Clamp(value, 1.0m, 10.0m)rounded to one decimal withMidpointRounding.ToEven.SystemPrompt(:233) carries the ADC track list, the six weighted criteria, calibration rules ("most talks should fall between 5.5 and 7.5"), and the 0.0/0.5/1.0 penalty ladder, ending withUntrustedInputBrief(:274-281), aninternal constkept separate only so the evaluation suite can assert on it by name.BuildUserPrompt(:288) is the containment half: a delimited, escaped envelope (<session_proposal>,<session_title>,<session_description>) rather than labelledTitle:/Description:lines, with the reasoning recorded in the comment above it (:283-287).FormatSpeakers(:299) renders each speaker's name, tagline and bio inside<speaker>tags, or the literal<speakers>(no speaker information available)</speakers>when there are none (:301-302).Escape(:347) replaces<and>with entities.RenderPrompt(:335) is the one public affordance for testing: the exact system-plus-user pair this service would send without calling the model, so the evaluation suite can hash it perPromptVersion.FailedResult(:354) is the single shape of failure: every score0m,Reasoning = "Scoring failed",Success = false.ScoreSchema(:370, built byBuildScoreSchemaat:372) is the structured-output schema: the six criteria as numbers,penaltyrestricted to0,0.5or1, a stringreasoning, all required andadditionalProperties = false.
- A primary constructor injects
- Why it's built this way: concentrating vendor specifics behind
IChatClientmakes swapping providers or faking the service in tests a one-class change; the never-throw-for-scoring, clamp, and all-or-nothing-parse discipline makes raw model output safe to persist into SessionAiScore. Moving AiScoreResponse to its own file, rather than nesting it as before, is what lets SessionScoreResponseGuardrail share the exact same shape and completeness check. - Where it's used: registered as the
IAiScoringServiceimplementation by DependencyInjection; injected into SessionScoringRunner (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/SessionScoringRunner.cs:27), which calls it per session and is in turn driven by the durable internal-command handler ScoreEventSessionsInternalCommandHandler (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Application/Sessions/UseCases/DecisionSupport/ScoreEventSessions/ScoreEventSessionsInternalCommandHandler.cs:47-48), which runs the scoring pass under a per-event distributed lock (ADR-114). Exercised directly bySessionScoringServiceTests,SessionScoreResponseGuardrailTests, and throughRenderPromptby theMMCA.ADC.Conference.Scoring.Evaluation.Testsproject (PromptContractTests,GoldenReplayTests,LiveJudgeTests). - Caveats / not-in-source: there is no retry inside this class. Whatever resilience the chat call carries is the framework's
IChatClientpipeline's concern, configured whereAddMmcaChatClientis called; the in-class contract stays "never throw for a scoring failure, let the batch continue". TheAi:Timeoutvalue itself is not read here: the class only reacts to the cancellation the governed client raises when it expires.
SessionScoreResponseGuardrail
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Sessions.Scoring·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoreResponseGuardrail.cs:34· Level 4 · class (sealed)
- What it is: an
IChatGuardrailthat refuses a session-scoring response before it reaches SessionScoringService, for three cases: a provider refusal, an empty answer, or text that is not a complete AiScoreResponse. - Depends on: first-party:
IChatGuardrail(implements), AiScoreResponse,PromptContract(ReadName), SessionScoringService.PromptName. External:Microsoft.Extensions.AI(ChatOptions,ChatResponse,ChatFinishReason,GuardrailVerdict),System.Text.Json,System.Globalization,System.Text.CompositeFormat. - Concept introduced, a response-side guardrail scoped to one prompt.
AppliesTo(:534-535) reads the prompt contract's name offChatOptionsand compares it toSessionScoringService.PromptName, so this guardrail only ever inspects session-scoring calls and allows everything else through unexamined.[Rubric §16, AI-Native Application Architecture]assesses whether an evaluation gate sits at the pipeline boundary rather than inside the caller: the same completeness check the scorer would apply is applied here first, so a malformed answer never reaches SessionScoringService at all.[Rubric §29, Resilience & Business Continuity]assesses graceful degradation: three named block reasons (RefusalReasonFormat:512-513,EmptyResponseReason:516-517,MalformedResponseReason:523-524) give the caller a specific, logged reason for every refusal instead of one generic failure. - Walkthrough:
InspectRequestAsync(:539-543) andInspectStreamedUpdateAsync(:558-562) both always allow, the first because the guardrail has nothing to say about what goes out, the second because the scorer does not stream.InspectResponseAsync(:546-554) delegates toInspectonlyAppliesToreturns true.Inspect(:564-595) checksIsRefusal(response.FinishReason)first (:566-572), then blocks on a null-or-emptyresponse.Text(:575-578), then deserializes the text into AiScoreResponse withAiScoreResponse.JsonOptions, blocking on aJsonException(:583-590) and onIsCompletebeing false (:592-594).IsRefusal(:603-606) treats both the normalizedChatFinishReason.ContentFilterand the raw string"refusal"(case-insensitive) as a refusal, because Microsoft.Extensions.AI does not normalize every provider's own refusal signal. - Why it's built this way: pulling the completeness check out to a guardrail, rather than leaving it solely inside the scorer, means the check runs identically for the deployed pipeline and for every evaluation tier that composes guardrails through DependencyInjection
.AddConferenceAiGuardrails, so a guardrail change cannot silently start scoring on incomplete data in one tier and not another. - Where it's used: registered as a singleton
IChatGuardrailby DependencyInjection.AddSessionScoreResponseGuardrail, called fromAddConferenceAiGuardrails. Exercised directly bySessionScoreResponseGuardrailTestsand byConferenceAiGuardrailsRegistrationTests.
SessionizeService
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Events.Sessionize·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Events/Sessionize/SessionizeService.cs:12· Level 4 · class (sealed)
- What it is: the HTTP implementation of ISessionizeService. It calls the Sessionize "View All" endpoint, which returns every session, speaker, room and category for a conference in one document, after validating that the caller-supplied Sessionize code is well-formed.
- Depends on: first-party: ISessionizeService (implements),
SessionizeResponse(return shape), SessionizeCodeFormat (the format validator),Result<T>/Error(the framework Result pattern). External:HttpClient,System.Net.Http.Json. - Concept introduced, a second-layer format check ahead of a redirect-capable URI segment.
[Rubric §11, Security]assesses defense against SSRF-shaped input:sessionizeCodebecomes the leading segment of a relative URI resolved against the configured Sessionize base address, and the inlineSECURITYcomment (:14-19) spells out the exact attack, RFC 3986 resolution reads a code that starts//host/pathas a network-path reference, so an unchecked code redirects the request to a foreign host whose JSON gets imported as speakers, sessions and rooms. Request validators already enforce the charset at the boundary; this check is deliberately a second layer, because it is the one that still covers a code written to the database before that validator rule existed.[Rubric §2, Design Patterns]and[Rubric §1, SOLID](dependency inversion) assess whether the application depends on an abstraction it owns: the Application layer declares the port, Infrastructure supplies the adapter, and no Application file referencesHttpClient. - Walkthrough: a primary constructor takes
HttpClient(:12).GetAllAsyncnow returnsTask<Result<SessionizeResponse?>>rather than a bare nullable task (:14). It first checksSessionizeCodeFormat.IsValid(sessionizeCode)(:21) and, on a bad format, returnsResult.Failure<SessionizeResponse?>with the invariant errorEvent.SessionizeCode.InvalidFormat(:22-27), namingSessionizeServiceas the source andsessionizeCodeas the target. Past that gate the wire call is unchanged: it builds the relative URI{sessionizeCode}/view/All(:31), GETs it (:32-34), callsEnsureSuccessStatusCode()(:36), and now wraps the deserializedSessionizeResponse?inResult.Success(:38-40). Both awaits use.ConfigureAwait(false), the repo-wide library rule from ADR-049. - Why it's built this way: keeping parsing, mapping and the import workflow in Application use-cases leaves this adapter owning only the wire call and the one input check that only Infrastructure can make (the value that becomes part of a URI), which is what keeps it trivially fakeable in tests.
- Caveat, error handling still differs from the AI adapter.
EnsureSuccessStatusCode()throwsHttpRequestExceptionon any non-2xx, and that exception propagates out of the class, the opposite of SessionScoringService's never-throw contract; only the new format check returns aResultfailure instead of throwing. The difference still follows the shape of the work: a Sessionize sync is one explicit organizer action where a failure should surface as an error, while AI scoring is a per-item batch where one item's failure must not stop the rest. The success payload is also nullable, so a 2xx with an empty body yields a successfulResultwrappingnullrather than an exception. - Where it's used: the Sessionize import handlers in Conference Application, triggered when an organizer refreshes an event's data.
ConferenceModuleDbSeeder
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts.Seeding·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:35· Level 9 · class
- What it is: the Conference module's startup data seeder. It puts the two real conference editions and the ten feedback questions into a fresh database on every boot, and, only when its
includeSampleDataflag is set, a small deterministic browse fixture on top (two speakers, two sessions, the speaker links, four sponsors, four partners, three social activities). Every write goes through a domain factory and the unit of work, and every insert is guarded by an existence check, so running it against an already-seeded database is a no-op, and running it on several replicas at once is safe (the class remarks say so,:28-33). - Depends on: first-party:
DbSeeder(base,:35),IUnitOfWork(:35,:70) and theIRepository<TEntity, TIdentifierType>handles it hands out through_unitOfWork.GetRepository<...>()in eachSeed*Asyncmethod, the domain factoriesEvent(:114,:149),Question(:203),Speaker(:243),Session(:307),Sponsor(:415),Partner(:475) andActivity(:544), the join entitiesEventSpeakerandSessionSpeaker(created indirectly inLinkSampleEventSpeakersAsync:360-379andLinkSampleSessionSpeakersAsync:630), the id-range constants onQuestionInvariants(:177,:199) andSessionInvariants(:285-286), andSponsorTier(inSeedSponsorsAsync,:381-438). External:DbUpdateExceptionandEntityStatefrom EF Core and the framework'sSqlServerUniqueConstraintViolationDetector(:68,:580), plus the BCL (DateOnly,TimeOnly,TimeSpan,DateTimeKind). - Concept introduced, seeding through the domain rather than through SQL. A seeder in this codebase never writes rows. It calls the same static factory an HTTP command handler would call, checks the returned
Resultwrapper, hands the entity to a repository, and letsIUnitOfWorkcommit.Event.Create(...)at:114-126is the identical entry point the organizer's create-event use case takes, so seeded data satisfies exactly the invariants that user-created data satisfies: there is no second, looser definition of a valid event hiding in the seeder. The consequence worth internalizing is that a seed insert is a full domain write with all its side effects:eventResult.Value!.Publish()at:131flipsIsPublishedand raises anEventChangeddomain event (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Events/Event.cs:314-316), so the save that follows (SaveToleratingConcurrentSeedAsyncat:134, which calls_unitOfWork.SaveChangesAsyncat:578) writes an outbox row alongside the entity row. Startup seeding therefore feeds the same dual-dispatch pipeline as runtime traffic (ADR-003), it does not bypass it. This is what Rubric section 4 (DDD) assesses whether the domain model is the single place invariants live; routing seed data through the factories is what keeps that true at the one moment it is most tempting to cheat. - Concept introduced, idempotency that respects soft delete and survives a rename. Every existence probe in this file passes
ignoreQueryFilters: true(:108,:143,:179,:237,:298,:409,:469,:536), which turns off the global soft-delete filter for that one query. The comment at:95-104gives both halves of the reason. First, a deleted seed row is a decision someone made, not a gap to refill, so the seeder must see it and stand down. Second, the fixed-id rows (questions at:199, sample sessions at:285-286) keep their primary keys when soft-deleted, so a filtered check would report "missing", re-insert the same id, and take startup down on a primary-key violation. The same comment records a second rule: each check keys first on an identity an organizer edit does not change, either the event's Sessionize code (CloudAiSessionizeCode:40,DevConSessionizeCode:43, tested at:106and:142), or the fixed primary key (q.Id == QuestionInvariants.ManualIdRangeStartat:177,s.Id == sampleIdat:297). The editable name or title stays as a second||identity, so renaming a seeded row through the ordinary update endpoints does not make the next startup seed it again, and re-seeding needs both a rename and a code change, which is a deliberate repurpose. TheExistsAsync(Expression<Func<TEntity, bool>>, bool ignoreQueryFilters, CancellationToken)overload that makes this possible is on the shared repository contract (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/Infrastructure/Persistence/IRepository.cs:62-65).ConferenceModuleDbSeederTestspins the behavior mechanically: its Moq setups matchtruefor that parameter only (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.Infrastructure.Tests/Seeding/ConferenceModuleDbSeederTests.cs:152-162), so a future edit that drops the flag falls through to Moq's defaultfalseand the "skips when exists" tests go red (the reasoning is spelled out in the comment at:99-102of that test file). This is what Rubric section 17 (DevOps) assesses whether startup is repeatable and safe to re-run; this is the pattern that makes "boot the app twice" a non-event. - Concept introduced, environment-gated fixture data. The class takes
bool includeSampleData = false(:35), stores it (:71), and branches on it inSeedAsync(:74-89, theifat:80). The default is off, and the only thing that turns it on isConferenceModuleSeederreadingconfiguration.GetValue<bool>("Seeding:IncludeSampleConferenceData")(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModuleSeeder.cs:26).GetValue<bool>on an absent key yieldsfalse, so a production host that never sets the key gets the real events and questions and nothing else. The one place the key is set is the local Aspire AppHost,.WithEnvironment("Seeding__IncludeSampleConferenceData", "true")on the Conference service (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:206, rationale at:207-209). E2E CI inherits it by launching that same AppHost (MMCA.ADC/.github/workflows/e2e.yml:219), which is why the two public-browse tests can assume rows exist (MMCA.ADC/Tests/E2E/MMCA.ADC.E2E.Tests/Workflows/Conference/Public/PublicBrowseTests.cs:93,:102). This is what Rubric section 11 (Security) assesses whether non-production affordances are structurally unable to reach production: here the gate is a default-false configuration read in the composition root, not a runtime environment sniff inside the seeder. - Concept introduced, reserved manual id ranges. Conference session ids are app-assigned rather than database-generated because the integer primary key is the Sessionize id when a session arrives through the import. Sample sessions have no Sessionize id, so they take explicit ids from the top of the integer space:
SessionInvariants.ManualIdRangeStartis999_999_000(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/SessionInvariants.cs:44) and the two fixtures take that value and that value plus one (:285-286). Questions do the same fromQuestionInvariants.ManualIdRangeStart, also999_999_000(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/QuestionInvariants.cs:48), incremented per question byid: nextId++(:199,:204). The range sits above any id an upstream system will mint, so seed rows and imported rows can never collide, and the same constants are what the organizer-facing create handlers continue from. This is what Rubric section 8 (Data Architecture) assesses whether key strategy is deliberate; reserving a high range is the cheap alternative to a separate identity column or a synthetic-vs-natural key split. - Concept introduced, tolerating a lost seeding race between replicas. Production runs
DatabaseInitStrategy=Migrateon every replica, so two replicas of the Conference service can start together, both pass every existence check, and both try to insert.SaveToleratingConcurrentSeedAsync(:574-594) is the single save every seed step now uses (:134,:169,:218,:261,:331,:357,:437,:493,:564). It wraps_unitOfWork.SaveChangesAsync(:578) and catches only aDbUpdateExceptionthat carries entries and that the staticSqlServerUniqueConstraintViolationDetector(ConcurrentSeedDetector,:68) classifies as a unique or primary-key violation (SQL Server 2601/2627, per the doc comment above it) (:580). The uniqueSessionizeCodeindex and the fixed question ids are what make the slower replica's save fail; the catch reads that failure as "another replica seeded these rows" and the run continues. Before continuing it detaches everyAdded,ModifiedorDeletedentry of each affected context (:582-592), the seed rows and the outbox rows the save captured for them, because the scoped context would otherwise replay the same rejected insert on the next step's save. Any otherDbUpdateExceptionis not caught and still takes startup down. This is what Rubric section 29 (Resilience & Business Continuity) assesses whether concurrent startup is safe by construction rather than by aminReplicasfloor. - Walkthrough
- Primary constructor and fields (
:35,:70-71):ConferenceModuleDbSeeder(IUnitOfWork unitOfWork, bool includeSampleData = false) : DbSeeder(). The unit of work is null-guarded into_unitOfWorkat:70(the one behavior a unit test asserts directly,ConferenceModuleDbSeederTests.cs:122-124); the flag is copied to_includeSampleDataat:71. The baseDbSeedercontributes theSeedAsyncabstract member and aGetId<TIdentifier>(int)helper for int-or-Guid key strategies (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Seeding/DbSeeder.cs:20-39), which this seeder does not use: the Conference identifier aliases areint, so literal ids pass straight through. - Literal constants (
:37-61, plus the detector at:68): the shared venue address and embedded map URL, the two Sessionize codes (:40,:43) that now double as immutable existence identities, three placeholder URLs for sample sponsors, partners and activities, and the two published sponsorship-packet URLs. The six URL constants each carry aSuppressMessagefor SonarS1075(URIs should not be hardcoded) with a per-constant justification, which is how this repo records "the literal is the data" rather than switching the analyzer off. SeedAsync(:74-89): the whole contract in a few lines. Three unconditional steps (:76-78) then a singleif (_includeSampleData)block of six (:80-87). Ordering is a real dependency chain: sessions need the events, the speaker links need both the speakers and the sessions, sponsors and partners need the events, and partners are seeded right after sponsors and before activities.SeedCloudAiConferenceEventAsync(:91-135): probes for the event by Sessionize code first and by name second, tolerating the pre-rename spelling so a database seeded before "2026" was prefixed stays idempotent (:105-109). On a miss it builds the 2026 Atlanta Cloud + AI Conference for 2026-05-30 inAmerica/New_YorkwithCloudAiSessionizeCode(z1ecmzux,:114-126), returns quietly if the factory fails, publishes (:131), adds and saves through the tolerant save (:134).SeedDevelopersConferenceEventAsync(:137-170): the same shape for the 2026 Atlanta Developers Conference on 2026-10-17,DevConSessionizeCode(sf1nopko,:149-162). Its probe (:142) is the code or the name, and it needs no legacy-name alternative because that event was never renamed.SeedQuestionsAsync(:172-219): one probe decides the whole set, now keyed on the first reserved idQuestionInvariants.ManualIdRangeStartor the sentinel question"Rate the Session"withQuestionSource == "User"(:177-180), then ten tuples become tenQuestion.Createcalls with sequential reserved ids (:199-204) and a single tolerant save (:218). Six are session-scoped (five ratings plus a free-text comment), four are event-scoped. TheTimes.Exactly(10)assertion in the unit tests (ConferenceModuleDbSeederTests.cs:28-30) is what keeps that count honest.SeedSpeakersAsync(:221-262): two sample speakers, Ada Lovelace and Alan Turing onexample.comaddresses, each probed by first and last name (:237) and skipped individually withcontinuerather than aborting the batch. Anaddedflag means the save (:261) only runs when something actually changed. That flag pattern repeats in every sample-data method.SeedSessionsAsync(:264-332): resolves both events through the shared helper, then seeds one session per event (Ada's keynote on the Cloud + AI day, Alan's Azure talk on the Developers day,:285-286) so both the auto-filtered public pages and the organizer list's event filter have data for either selection. Start time is computed from the owning event's own date,sessionEvent.StartDate.ToDateTime(new TimeOnly(13, 0), DateTimeKind.Utc), with a comment recording that 13:00 UTC is 09:00 Eastern for both dates; the session runs one hour. The probe is by reserved id or title (s.Id == sampleId || s.Title == title,:297), so a rename of a seeded session does not re-seed it. A comment notes that a database seeded before the one-session-per-event split keeps its old shape: the probe never moves an existing row.SeedSampleEventLinksAsync(:334-358): loads the two sample speakers withasTracking: false, bails if either is missing, then calls the two link helpers and ORs their results before one tolerant save (:357). The comment above the OR explains why both link paths are populated: the speakers-by-event filter has a direct branch (throughEventSpeaker) and a transitive branch (throughSessionSpeaker), and seeding both exercises both in dev and CI.LinkSampleEventSpeakersAsync(:360-379) andLinkSampleSessionSpeakersAsync(:615-635): both re-fetch withasTracking: trueand anincludeslist so the aggregate's child collection is loaded before mutation (nameof(Event.EventSpeakers)in the first;nameof(Session.SessionSpeakers)at:620). Idempotency is delegated to the domain:AddEventSpeakerandAddSessionSpeakerreturn a failedResulton an existing non-deleted link, so.IsSuccessdoubles as the "did anything change" signal (:630for the session link).SeedSponsorsAsync(:381-438): four sample sponsors spanning both events and all fourSponsorTiervalues, two of them exhibitors with booth numbers, each probed by name (:409) and built throughSponsor.Create(:415), then one tolerant save (:437). The mapping these rows land in isSponsorConfiguration.SeedPartnersAsync(:440-494): four sample partners spanning both events, three attached to the Developers edition,CommunityandCommunityandSpecial, and one attached to the Cloud + AI edition,Community, so the landing-page band renders fully in dev and CI on the Developers side while the organizer list still has a row whichever event its filter defaults to (explained in the method's own comment). Each is probed by name (:469) and built throughPartner.Create(:475). The mapping these rows land in isPartnerConfiguration.SeedActivitiesAsync(:496-565): three social-programme entries expressed as event-local wall-clock offsets rather than absolute timestamps, a pre-conference party the evening before (DayOffsetof-1), a morning coffee, and an after-party. Each is anchored on its own event's start date,activityEvent.StartDate.AddDays(dayOffset).ToDateTime(TimeOnly.MinValue), and the venue URL is only supplied when a venue name exists. Probe at:536, factory at:544, tolerant save at:564.GetSampleEventsAsync(:596-613): the one static helper. A singleGetAllAsyncfetches both events (again tolerating the pre-rename Cloud + AI name,:604) withasTracking: trueso callers can mutate them, then splits the result by name. Callers pass theincludesthey need, which is why the same helper serves both the no-include sponsor and partner paths and theEventSpeakers-include link path. Unlike the existence probes it still locates events by name, not by Sessionize code.
- Primary constructor and fields (
- Why it's built this way: this is the file that decides what a freshly provisioned Conference database contains, so it is written to be safe under four conditions that are easy to get wrong. Re-running (guarded by existence checks that see through soft delete and key on immutable identities first), running against production (fixture data behind a default-false configuration key), running against a database whose real content arrives from elsewhere (reserved id ranges that cannot collide with Sessionize ids), and several replicas booting at once (unique-violation-tolerant saves). Doing the writes through domain factories and
IUnitOfWorkrather than raw SQL or EFHasDataalso means seeding participates in the audit stamping, soft-delete defaults, and outbox dispatch thatApplicationDbContextapplies to every save, which matters because per-service databases (ADR-006) each get their own seeding pass at their own host's startup. - Where it's used: instantiated by
ConferenceModuleSeeder, the module'sIModuleSeederadapter, which resolvesIUnitOfWorkandIConfigurationfrom the scope and constructs it with the gate flag (ConferenceModuleSeeder.cs:21-29). That adapter is discovered by reflection inModuleLoader(MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:103-104) and invoked in registration order bySeedAllAsync(ModuleLoader.cs:266-272), which the host calls fromDatabaseInitializationExtensionsafter schema initialization (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:137). Its output is what the public browse pages in Group 21 render in dev and CI, and what the E2E feedback and share workflows target by the reserved session id. - Caveats / not-in-source: (1) Failures are swallowed. A failed factory
Resultproduces a barereturnorcontinuewith no logging: the class takes no logger, so a seed that silently does nothing leaves no trace beyond the missing rows. ThePublish()calls at:131and:166likewise discard theirResult. The tolerant save adds a second silent path: a caught unique or primary-key violation is neither logged nor counted (:580-593). (2) The class ispublicand notsealed, unlike most types in this Infrastructure assembly. (3) Idempotency keys are the Sessionize code, the reserved id, and for the rest names and titles, not database constraints: only the eventSessionizeCodeindex and the primary keys are enforced by the database, so nothing stops a second row with the same sponsor name, partner name, or activity from being created through the normal command paths, after which the seeder's probe would simply keep finding a match. Two replicas racing on a sponsor, partner or activity, which have no unique index, can both insert. (4) A comment inSeedSessionsAsyncdocuments a real migration gap: databases seeded before the sessions were split across the two events keep the old shape and there is no fix-up path in this file, only the advice to reset the local container volume. (5) The sample-data insert order is not transactional across methods. Each method saves independently (:261,:331,:357,:437,:493,:564), so a crash partway through leaves a partially seeded fixture, which the existence checks will then complete on the next boot. (6)ConferenceModuleDbSeederTestscovers only the always-on path withincludeSampleDataleft at its default (ConferenceModuleDbSeederTests.cs:164); the six sample-data methods, now includingSeedPartnersAsync, have no unit-test coverage in that file and are exercised indirectly through E2E.
DependencyInjection
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/DependencyInjection.cs:18· Level 5 · class (static)
- What it is: the DI wiring for Conference Infrastructure. It registers the one remaining outbound HTTP integration (Sessionize) as a typed client, wires the AI scoring adapter against the framework's governed chat client, registers the output-cache eviction adapter the internal-command scoring pass depends on, and composes the three AI guardrails the Conference module runs.
- Depends on: first-party: ISessionizeService with SessionizeService, IAiScoringService with SessionScoringService, ISessionScoresCacheEvictor with OutputCacheSessionScoresCacheEvictor,
IChatGuardrailwith SessionScoreResponseGuardrail. External:Microsoft.Extensions.DependencyInjection,Microsoft.Extensions.AI(IChatClient,IChatGuardrail). - Concept introduced, what moved to the framework and what stayed. The comment above the Sessionize registration is itself a piece of documentation: provider error classification (
IUniqueConstraintViolationDetector,IConcurrencyConflictDetector) used to be registered by this module and is now framework surface added by MMCA.Common'sAddInfrastructure. The AI scoring registration comment (:763-767) carries the larger version of the same story: the transport, the credential, the model, the output ceiling, the per-call timeout and the tool gate all belong to the framework's governedIChatClientnow, over whichever provider adapterAi:Providernames, so nothing in this file constructs anHttpClientor names a vendor (ADR-120).[Rubric §3, Clean Architecture]and[Rubric §7, Microservices Readiness]assess how cleanly a module's registration surface shrinks as its responsibilities move to shared framework surface without the module losing the ability to run as its own service. - Walkthrough: a single
extension(IServiceCollection services)block (:746, the codebase's standard DI idiom, see the primer's extension(T) note).AddModuleConferenceInfrastructure()(:753): typed client for Sessionize, base addresshttps://sessionize.com/api/v2/(:759-761);services.TryAddScoped<IAiScoringService>(serviceProvider => new SessionScoringService(...))(:774-777), a factory registration rather than a constructor-injectedAddHttpClientpair because SessionScoringService needsIChatClientresolved withGetService(nullable, the disabled path) alongsideSessionScoringService.SessionScoringContractand a requiredILogger<SessionScoringService>;services.TryAddScoped<ISessionScoresCacheEvictor, OutputCacheSessionScoresCacheEvictor>()(:784), with a comment (:779-783) noting the hosted drain and crash-recovery sweep this method used to own are gone, the framework's internal-command processor now owns the durability, claim lease and retry backoff. Returnsservicesfor chaining (:786).AddConferenceAiGuardrails(IConfiguration configuration)(:812): the single composition point for the Conference AI pipeline's three guardrails, shared by the service host and by every evaluation tier. Its remarks (:797-803) state the reason it exists at all: when each caller assembled its own guardrail list, the golden replay suite drove the scorer bare and the live judge registered only one of the three, so the tiers that exist to judge the deployed pipeline were judging a different pipeline. It callsservices.AddPiiRedactionGuardrail()andservices.AddContentPolicyGuardrail(configuration)(:822-823, both framework guardrails, request side) thenservices.AddSessionScoreResponseGuardrail()(:826, this module's, response side). Its remarks (:805-809) warn it must run BEFOREAddMmcaChatClient, which reads theIChatGuardraildescriptors to decide whether to compose the guardrail layer at all; every registration isTryAddEnumerable, so calling it twice composes the same three.AddSessionScoreResponseGuardrail()(:851): registers SessionScoreResponseGuardrail withservices.TryAddEnumerable(ServiceDescriptor.Singleton<IChatGuardrail, SessionScoreResponseGuardrail>())(:855-856). Kept public and separate fromAddConferenceAiGuardrails, which composes it (remarks:843-849), so a caller that wants only the response gate has it, and stacks with the framework's guardrails instead of replacing them.
- Why it's built this way: registering
SessionScoringServicefrom a factory lambda rather than a typedAddHttpClientcall is the direct consequence of depending on a shared, already-configuredIChatClientinstead of owning a client's lifecycle. Splitting guardrail composition into its own method, called before the host wiresAddMmcaChatClient, is what lets one registration point reach both the deployed pipeline and every evaluation tier, so a guardrail cannot change production scores without an evaluation case seeing the same guardrail. - Where it's used:
AddModuleConferenceInfrastructureis called from the Conference module's registration chain (see ConferenceModule), which the module loader invokes in topological order;AddConferenceAiGuardrailsis called by the service host and by the evaluation tiers ahead ofAddMmcaChatClient.
ModuleApplicationDbContext
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.DbContexts·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:22· Level 12 · abstract class
- What it is: an abstract
DbContextthat names the Conference module's entity sets. It declares seventeeninternal DbSet<T>properties and forwards its four constructor arguments unchanged to the framework base; it adds noOnModelCreating, noOnConfiguring, and no behavior of any kind. - Depends on: first-party:
ApplicationDbContext(base,:25),IEntityConfigurationAssemblyProvider(:23),PhysicalDataSource(:24), and the seventeen Conference entity types it exposes, all from Group 17 exceptSessionAsset(mapped bySessionAssetConfigurationin this chapter) andPartner(mapped byPartnerConfigurationin this chapter):Event,Room,EventSpeaker,EventQuestionAnswer,Session,SessionSpeaker,SessionQuestionAnswer,SessionCategoryItem,SessionAsset,Speaker,SpeakerCategoryItem,Category,CategoryItem,Question,Sponsor,Partner,Activity. External:Microsoft.EntityFrameworkCore.DbContextOptionsandDbSet<T>(:1). - Concept introduced, a
DbSetlist is an index, not the model. The instinct carried over from a typical EF application is thatDbSet<T>properties define what the context maps. In this codebase they do not. The model is built byApplicationDbContext, which walks the assemblies handed to it byIEntityConfigurationAssemblyProviderand applies every configuration implementing the engine's interface, filtered to the entities routed to this physical data source (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:954-987). The proof is in the arithmetic: the ConferenceEntityConfigurationfolder holds nineteen*Configuration.csfiles (seventeen plusSessionAssetConfigurationandPartnerConfiguration), two more than the seventeenDbSets here.SessionAiScoreandSpeakerQuestionAnswerare mapped, migrated, and queried without ever appearing on this class. Reading theDbSetlist as a coverage manifest would therefore mislead you; read the configuration folder instead. This is what Rubric section 8 (Data Architecture) assesses whether the mapping strategy is explicit and centrally governed; convention-by-assembly-scan is what lets one context class serve every module without any module editing it. - Concept introduced,
internalsets as a layering boundary. All seventeen properties areinternal(:28-78), notpublic. Nothing outsideMMCA.ADC.Conference.Infrastructurecan reachcontext.Sessionseven with a context instance in hand. Application-layer handlers get their data throughIUnitOfWorkandIRepository<TEntity, TIdentifierType>instead, which is where soft-delete filtering, tracking choices, and data-source routing are decided. This is what Rubric section 3 (Clean Architecture) assesses whether the dependency rule is enforced by the compiler rather than by convention; an access modifier is the cheapest available enforcement, and it is why a handler cannot accidentally grow a raw LINQ query against aDbSet. - Walkthrough
- Class declaration and primary constructor (
:20-25):public abstract class ModuleApplicationDbContext(DbContextOptions options, IServiceProvider serviceProvider, IEntityConfigurationAssemblyProvider assemblyProvider, PhysicalDataSource physicalDataSource) : ApplicationDbContext(options, serviceProvider, assemblyProvider, physicalDataSource). Every parameter is passed straight through; the class captures none of them and overrides nothing. Note the untypedDbContextOptionsrather thanDbContextOptions<TContext>, which is what allows an arbitrary concrete subclass to supply its own typed options. - The seventeen entity sets (
:28-78):Events(:28),Rooms(:31),EventSpeakers(:34),EventQuestionAnswers(:37),Sessions(:40),SessionSpeakers(:43),SessionQuestionAnswers(:46),SessionCategoryItems(:49),Speakers(:52),SpeakerCategoryItems(:55),Categories(:58),CategoryItems(:61),Questions(:64),Sponsors(:67),Partners(:70),Activities(:73),SessionAssets(:77). Read top to bottom they trace the module's aggregate map: the two roots that own schedules (Event,Session), their join tables to speakers and categories, the taxonomy pair (CategoryandCategoryItem), the feedback pair (Questionplus the two answer tables), the three per-event additions (Sponsor,Partner,Activity), and the newest set,SessionAssets, appended at the end rather than grouped with the other session-scoped sets.Partnerswas inserted betweenSponsorsandActivities, next to the entity it mirrors, unlikeSessionAssets. - What is deliberately absent: no
OnModelCreatingoverride, so nothing here competes with the base's assembly scan; noOnConfiguring, so provider selection stays with the concrete engine context; noSaveChangesoverride, so audit stamping, soft delete, and outbox dispatch remain the base's job.
- Class declaration and primary constructor (
- Why it's built this way: ADR-006 fixes one sealed context class per engine, shared across modules, rather than one context class per module, and the runtime honors that literally. The concrete context is Common's
sealed class SQLServerDbContext, which derives fromApplicationDbContextdirectly (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/SQLServerDbContext.cs:15-20), and the Conference migrations project targets that same type throughIDesignTimeDbContextFactory<SQLServerDbContext>(MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/DesignTimeSQLServerDbContextFactory.cs:12-15). Keeping the module-level class abstract, behavior-free, and additive means the per-engine story stated in ADR-018 (SQL Server today, Cosmos and SQLite as further engines) needs no per-module change: a new engine adds one sealed class in MMCA.Common, not seventeenDbSetdeclarations per module. This is what Rubric section 7 (Microservices Readiness) assesses whether a module could be lifted into its own host without a rewrite; the entity-set surface being module-scoped andinternalis part of what makes that lift mechanical. - Where it's used: the seventeen sets correspond one to one with seventeen of the nineteen configurations in this chapter, including
EventConfiguration,SessionConfiguration,SponsorConfiguration,PartnerConfiguration,ActivityConfigurationandSessionAssetConfiguration, and with the tables the Conference migrations project maintains for theADC_Conferencedatabase. Application-layer access to those tables runs throughIUnitOfWork, and the rows themselves are first written byConferenceModuleDbSeeder. - Caveats / not-in-source: (1) Nothing derives from this class. A repository-wide search of MMCA.ADC for the identifier
ModuleApplicationDbContextreturns exactly three hits, the three sibling declarations in the Conference, Engagement, and Identity Infrastructure projects, and no subclass, no DI registration, and no consumer. The class compiles and is packaged, but it is not on the runtime path today:SQLServerDbContextbypasses it. Treat this section as documentation of the module's entity surface and of an extension point that is currently unexercised, not of a type in the request path. (2) Consequently theinternalvisibility of the sets protects a surface nothing currently reaches; the layering point it makes is real but presently theoretical for this class. (3)SessionAiScoreandSpeakerQuestionAnswerhave configurations inMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/but noDbSethere; whether that is deliberate or an oversight is not determinable from source, since no code reads theDbSetlist. (4) The three siblingModuleApplicationDbContextclasses share a name across three namespaces (Conference at:20, Engagement atMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:19, Identity atMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:15); each has its own section in its own chapter, so check the namespace before assuming which one a search result refers to.
CategoryItemConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Categories/CategoryItemConfiguration.cs:10· Level 8 · class
What it is: the EF Core persistence map for the
CategoryItementity: column facets, the parent relationship toCategory, and a composite unique index. It is the smallest complete member of the seventeen-class configuration family in this folder, so it is the one this chapter uses to teach the shared shape.Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>(base,:11),CategoryItem,Category,CategoryInvariants(:19). External:Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<T>.Concept introduced, the per-entity configuration class and what the base already did. Every configuration in this folder is an
internal sealed classderiving fromEntityTypeConfigurationSQLServer<TEntity, TIdentifierType>and overriding one method,Configure(EntityTypeBuilder<TEntity> builder), whose first statement is alwaysbase.Configure(builder)(:16). Knowing exactly what that base call does is what stops you re-declaring things by hand:EntityTypeConfigurationSQLServeris a shim with no body (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Configuration/EntityTypeConfiguration/EntityTypeConfigurationSQLServer.cs:17-20). Its whole contribution is the[UseDataSource(DataSource.SQLServer)]attribute it carries (:16), an instance ofUseDataSourceAttribute.- The real work is in
EntityTypeConfiguration<TEntity, TIdentifierType>. ItsConfigure(EntityTypeConfiguration.cs:37) reads the attribute offGetType()and throws if it is missing (:43-46), then callsApplyEngineConventions(:48). ForDataSource.SQLServerthat meansToTable(typeof(TEntity).Name, NamespaceConventions.GetModuleName(typeof(TEntity)) ?? "dbo"), so the table name comes from the CLR type and the schema comes from the module segment of the entity's namespace (:66), thenHasKey(p => p.Id)(:67) and eitherValueGeneratedOnAdd()orValueGeneratedNever()depending onIsIdValueGenerated(:68-71). - Below that,
EntityTypeConfigurationBase<TEntity, TIdentifierType>does exactly one thing:builder.Ignore(nameof(AuditableAggregateRootEntity<>.DomainEvents))for aggregate roots (EntityTypeConfigurationBase.cs:29-32), keeping the in-memory event list out of the schema. - What the base chain does not do is equally important. The soft-delete global query filter, the
rowversionconcurrency token and the soft-delete index convention are installed by the context, not by these classes:ApplicationDbContextadds the query filter atApplicationDbContext.cs:428, marks the concurrency property at:469and:473, and registersSoftDeleteUniqueIndexConventionat:296. So a configuration class in this folder is only ever about this entity's columns, relationships and indexes.
Because the engine is pinned entirely by the base type, re-pointing a Conference entity at SQLite or Cosmos is a base-class swap with no edit to the body of
Configure: the domain entity, the handlers and everything above stay untouched. All seventeen Conference configurations use the SQL Server base, since ADC runs SQL Server only.[Rubric §8, Data Architecture]assesses whether persistence is designed deliberately (typed lengths, correct nullability, FK relationships, purposeful indexes) rather than left to convention defaults: this family is where all of that lives for the Conference database.[Rubric §3, Clean Architecture]assesses dependency direction: EF mapping is confined to Infrastructure, and the domain entities carry zero EF attributes, so the domain layer stays framework-free.Concept introduced, length constants sourced from the domain invariants. Nearly every
HasMaxLengthcall in this folder reads a constant from the entity's…Invariantsclass instead of a literal. Here it isCategoryInvariants.CategoryItemNameMaxLength(:19). The same constant is what the Application layer's FluentValidation rules use, so the column width and the request validator are a single source of truth: change the constant once and both move.[Rubric §15, Best Practices & Code Quality]assesses exactly this kind of single-definition-point discipline.Walkthrough
- Class declaration (
:10-11):internal sealed class CategoryItemConfiguration : EntityTypeConfigurationSQLServer<CategoryItem, CategoryItemIdentifierType>.internalbecause nothing outside this assembly configures the model; the second type argument is the module's identifier alias, not a raw CLR type. base.Configure(builder)(:16): tableCategoryItem, schemaConference, key onId, value generation per the entity.- Column facets (
:18-23):NameisHasMaxLength(CategoryInvariants.CategoryItemNameMaxLength).IsRequired();SortisIsRequired(). - Parent relationship (
:25-28):HasOne(p => p.Category).WithMany(p => p.CategoryItems).HasForeignKey(p => p.CategoryId).IsRequired(). Both ends of the navigation are named, so EF maps the aggregate's real collection property rather than inventing a shadow one. - Composite unique index (
:30-31):HasIndex(p => new { p.CategoryId, p.Name }).IsUnique(). Note there is no explicit filter call here, and none is needed:SoftDeleteUniqueIndexConventionruns at model finalizing and adds theIsDeleted = 0predicate to every unique index on a soft-deletable entity that does not already declare a filter (SoftDeleteUniqueIndexConvention.cs:52-56). The shipped index is therefore filtered, so soft-deleting a category item frees its(CategoryId, Name)slot for re-use.
- Class declaration (
Why it's built this way: one small single-responsibility class per entity keeps the domain a pure POCO set (the Clean Architecture dependency rule) and lets EF discover configurations by assembly scan, so adding an entity is "add a config class" with no central registration edit.
Where it's used: discovered and applied when the concrete
SQLServerDbContextbuilds its model; the resulting schema is snapshotted by the Conference migrations project (MMCA.ADC.Migrations.SqlServer.Conference). See also the declarative table-surface manifestModuleApplicationDbContext.
ConferenceCategoryConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Categories·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Categories/ConferenceCategoryConfiguration.cs:13· Level 8 · class
- What it is: the persistence map for the
Categoryaggregate, the parent ofCategoryItem. It is the only configuration in the folder whose class name does not match{Entity}Configuration. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,Category,CategoryInvariants. External:Microsoft.EntityFrameworkCore(forToTable). - Concept: the shared shape is taught under
CategoryItemConfiguration; the only new idea here is the deliberate name/table split. - Walkthrough
- Class name (
:13-14): the type isConferenceCategoryConfiguration, notCategoryConfiguration. The XML doc (:8-12) gives the reason: more than oneCategoryconcept exists in the wider codebase vocabulary, and a distinct configuration class name avoids ambiguity for a reader scanning the folder. - Explicit table mapping (
:24):builder.ToTable("Category", "Conference"). The comment (:21-23) is honest that this is redundant, the base would already deriveCategoryfromtypeof(Category).NameandConferencefrom the namespace; it is written out for clarity given the class-name mismatch above. - Columns (
:26-35):Titlerequired atCategoryInvariants.TitleMaxLength;Sortrequired;Typeoptional with a literalHasMaxLength(100), one of the few places in the family that does not read a constant.
- Class name (
- Why it's built this way: naming the configuration for the bounded context rather than for the CLR type is a small readability trade: the class is findable by module, and the explicit
ToTablekeeps the physical target visible at the call site rather than implied by a base-class convention two files away. - Where it's used: same discovery path as the rest of the family (see
CategoryItemConfiguration). - Caveats / not-in-source: the doc comment (
:10-11) cites a Catalog-moduleCategoryas the collision being avoided. Catalog is a MMCA.Store module, not an ADC one, so within this repo nothing would actually collide; treat the comment as rationale carried over from the shared framework vocabulary.
ActivityConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Activities·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Activities/ActivityConfiguration.cs:11· Level 9 · class
- What it is: the persistence map for
Activity, a social or networking item attached to an event (a pre-conference party, a coffee connect, an after-party) that is deliberately not a session: no room, no speakers, and often an external venue carried on the row itself. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,Activity,ActivityInvariants,Event,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept introduced, indexing for the sort, not just for the filter. The second index (
:63-64) isHasIndex(p => new { p.EventId, p.StartTime, p.SortOrder }).HasSoftDeleteFilter(): non-unique, filtered, and composed in exactly the order the public agenda page consumes. The comment (:61-62) states the intent: the page filters by one event and orders by start time then sort order, so the composite serves the browse query directly instead of the database pulling the event slice and sorting it afterwards. This is the one place in the folder where an index's column order is chosen for an ORDER BY rather than for a lookup predicate, and it is worth reading alongside the narrower lookup index above it (:58-59, plainEventIdwith the same soft-delete filter). ContrastRoomConfiguration, whose paired indexes exist because the composite is filtered and the FK lookup wanted an unfiltered one; here both carry the filter, because every read of this table goes through the global query filter.[Rubric §12, Performance and Scalability]assesses whether index shape follows the queries that actually run. - Concept reinforced, event-local wall-clock time.
StartTimeandEndTimeare required plain date-times with no offset column (:29-33), and the comment (:27-28) is explicit that this mirrorsSession.StartsAt/EndsAt: the IANA zone lives once on the owningEvent(seeEventConfiguration) and is never repeated per row. The domain entity says the same thing atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Activities/Activity.cs:28-33. Storing one zone for the whole programme is what keeps a schedule internally consistent when an activity is moved.[Rubric §15, Best Practices & Code Quality]assesses single-definition-point discipline, and this is the time-zone instance of it. - Walkthrough
- Required (
:19-21,:29-33,:47-51):NameatActivityInvariants.NameMaxLength(200,ActivityInvariants.cs:13),StartTime,EndTime,SortOrder, and theEventIdscalar. - Optional (
:23-25,:35-45):Description(ActivityInvariants.DescriptionMaxLength),VenueName,VenueAddressandVenueUrl, eachIsRequired(false)with its own invariant-sourced max length. An absentVenueNameis a meaningful value rather than missing data: the domain invariant says so (ActivityInvariants.cs:38-40), and the public page falls back to the event venue. - Event relationship (
:53-56): requiredHasOne(p => p.Event).WithMany().HasForeignKey(p => p.EventId), with the parameterlessWithMany(), soEventexposes no activities collection. The same one-sided-navigation choice is made inSessionConfiguration: activities are read by explicit event-scoped queries, not by walking the event aggregate. - Indexes (
:58-59,:63-64): the filteredEventIdlookup, then the filtered(EventId, StartTime, SortOrder)browse index described above. Neither is unique, soSoftDeleteUniqueIndexConventionwould not have touched either one, which is why both spell outHasSoftDeleteFilter().
- Required (
- Why it's built this way: an activity is a first-class row rather than a flavour of session because it has a different shape (its own venue, no room, no speakers), and separating it keeps the session table free of columns that only apply to parties.
[Rubric §4, DDD]assesses whether the model names distinct concepts distinctly instead of overloading one entity with a type discriminator. - Where it's used: exposed as
DbSet<Activity> ActivitiesonModuleApplicationDbContext(ModuleApplicationDbContext.cs:75); read by the public agenda queries and written by the organizer-facing activity commands.
SessionAiScoreConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionAiScoreConfiguration.cs:11· Level 8 · class
- What it is: the persistence map for
SessionAiScore, the row that stores a language model's rating of one session across seven dimensions plus its written reasoning. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SessionAiScore,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept introduced, sizing a decimal column to the value's actual range. Each of the seven score columns is declared
HasPrecision(3, 1), that isdecimal(3,1): three total digits, one after the point (:22-48). That is the smallest exact-decimal shape that holds a one-decimal rating without the rounding surprises afloat/doublecolumn would introduce. Choosing exact decimal for a value that is compared and sorted, rather than binary floating point, is the point.[Rubric §8, Data Architecture]assesses type fidelity of stored values. - Concept reinforced, recording the provenance of derived data.
ModelUsed(:54-56, a literal max length of 100),PromptVersion(:61-63, a literal max length of 32) andReasoning(:50-52, a literal max length of 4000) are all three required, and all three are among the few columns in this folder whose lengths are written as literals rather than read from an invariants class. Persisting which model produced a score, which version of the prompt contract it was asked under, and the sentence explaining it, alongside the numbers is what makes an AI judgement auditable: you can tell after the fact whether a given score came from a model or a prompt you have since replaced. The comment abovePromptVersion(:58-60) records both the format, a dated contract versionyyyy-MM-dd.Nthat makes 32 characters generous, and the backfill: rows written before the column existed carry"legacy"from theAddSessionAiScorePromptVersionmigration's column default.[Rubric §13, Observability and Operability]assesses whether derived values carry enough context to be explained later. - Walkthrough: required
SessionIdscalar (:19-20); sevendecimal(3,1)required score columns,OverallScore,TopicRelevanceScore,DescriptionQualityScore,NoveltyScore,ActionableTakeawaysScore,DepthOrInsightQualityScore,CredibilityExperienceScore(:22-48); requiredReasoning,ModelUsedandPromptVersion(:50-63); andHasIndex(p => p.SessionId).IsUnique().HasSoftDeleteFilter()(:66-68), commented "One score per session (among non-deleted)" (:65). There is noHasOnerelationship toSession:SessionIdis a plain scalar, so the score row is not a child of the session aggregate. - Why it's built this way: keeping the score in its own table behind a unique-per-session index means re-scoring is a soft-delete plus insert (the filter frees the slot) rather than an in-place overwrite, and the previous scoring run stays on disk for comparison.
- Where it's used: written by the Conference scoring pipeline:
ScoreEventSessionsInternalCommandHandlerdrives one pass per event, callingSessionScoringService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoringService.cs:13, theIChatClientadapter) which is itself checked bySessionScoreResponseGuardrail(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Sessions/Scoring/SessionScoreResponseGuardrail.cs:11, which refuses a refusal, an empty answer, or a malformed score object) before a row lands here. - Caveats / not-in-source: this configuration only defines the table. Whether scoring runs in a given environment is a configuration and feature-gating question decided outside this file. Note also that
ModuleApplicationDbContextdeclares noDbSetforSessionAiScore(its fifteen sets are listed atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/ModuleApplicationDbContext.cs:30-75), and nothing breaks, because that manifest does not drive the model.
[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).
SpeakerCategoryItemConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerCategoryItemConfiguration.cs:11· Level 8 · class
- What it is: the persistence map for the
SpeakerCategoryItemjoin entity, which tags a speaker with aCategoryItem(locality, expertise and so on). - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SpeakerCategoryItem,Speaker,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept: an exact instance of the join-entity template taught under
EventSpeakerConfiguration. - Walkthrough: required
SpeakerId(:19-20) andCategoryItemId(:22-23);HasOne(p => p.Speaker).WithMany(p => p.SpeakerCategoryItems).HasForeignKey(p => p.SpeakerId).IsRequired()(:25-28);HasIndex(p => new { p.SpeakerId, p.CategoryItemId }).IsUnique().HasSoftDeleteFilter()(:30-32). TheCategoryItemend is intentionally left unmapped as a relationship, so the row belongs to the speaker aggregate alone. - Where it's used: the speaker-profile read paths join through it to resolve a speaker's tags.
SpeakerConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerConfiguration.cs:12· Level 8 · class
What it is: the persistence map for
Speaker: name, bio, social links, the optional link to an Identity user, and the one value-object column in the Conference module.Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,Speaker,SpeakerInvariants,NullableEmailValueConverter, and transitively theEmailvalue object. External:Microsoft.EntityFrameworkCore.Concept introduced, mapping a value object with
HasConversioninstead ofOwnsOne.builder.Property(p => p.Email).HasConversion(new NullableEmailValueConverter())(:42-43) round-trips theEmail?value object to a plain nullable string column. The converter (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conversions/EmailValueConverter.cs:60-71) passesnullstraight through on both legs (:67-68), so "no email" stays a SQLNULLrather than becoming an empty string or a failedEmail.Createcall. Two design points worth carrying forward:- Why
HasConversionand notOwnsOne: the backing column stays a plain string, so adopting the value object on a property that used to be astringis not a schema change (EmailValueConverter.cs:8-10). - Facets stay at the call site: the converter deliberately owns no length or requiredness, which is why
HasMaxLength(SpeakerInvariants.EmailMaxLength)andIsRequired(false)are chained here (:44-45). Those differ per entity and are not the converter's business (EmailValueConverter.cs:20-22).
[Rubric §4, DDD]assesses whether value objects survive the trip to storage instead of being flattened into primitives at the boundary.[Rubric §15, Best Practices & Code Quality]applies too: the conversion logic lives once in MMCA.Common, so every entity with an email gets identical semantics.- Why
Concept reinforced, the partially filtered unique index.
HasIndex(p => p.LinkedUserId).IsUnique().HasFilter("[LinkedUserId] IS NOT NULL")(:63-65) enforces the one-to-one User to Speaker link only among speakers that have one. Without the predicate, SQL Server would treat multipleNULLs as duplicates and allow at most one unlinked speaker, which would be nonsense. Note this one is a hand-written literal rather thanHasSoftDeleteFilter(), because the predicate is aboutLinkedUserId, not about soft delete; the soft-delete clause is not added on top, becauseSoftDeleteUniqueIndexConventionskips any index that already declares a filter (SoftDeleteUniqueIndexConvention.cs:54). A soft-deleted linked speaker therefore keeps holding itsLinkedUserIdslot.Walkthrough
- Required identity (
:20-26,:39-40):FirstName,LastName,IsTopSpeaker. - Optional profile (
:28-37,:47-61):Bio(no max length, sonvarchar(max)),TagLine,ProfilePicture,TwitterHandle,LinkedInUrl,GitHubUrl,WebsiteUrl, each length-capped fromSpeakerInvariantsexceptBio. - Email (
:42-45) and theLinkedUserIdindex (:63-65), described above. - Computed property excluded (
:68):builder.Ignore(p => p.FullName)keeps the derivedFullNameout of the schema. Ignoring computed properties explicitly is how this codebase keeps derived state a domain concern and off the table.
- Required identity (
Why it's built this way:
LinkedUserIdis a scalar with no FK, deliberately. The Identity user lives in a different service database, so a cross-database foreign key is not available under database-per-service; the unique index gives the guarantee the FK would have, within the one database that can enforce it. See ADR-006.[Rubric §7, Microservices Readiness]assesses whether the schema is already free of cross-service constraints, which is what makes the Conference service extractable.Where it's used:
Speakeris the owning aggregate forSpeakerCategoryItemConfigurationandSpeakerQuestionAnswerConfiguration, and the FK target of theSpeakerIdscalar inEventSpeakerConfigurationandSessionSpeakerConfiguration.
SpeakerQuestionAnswerConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Speakers·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerQuestionAnswerConfiguration.cs:10· Level 8 · class
- What it is: the persistence map for
SpeakerQuestionAnswer, a speaker's answer to a speaker-scopedQuestion(the fields Sessionize collects on a submission form). - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SpeakerQuestionAnswer,Speaker,SpeakerInvariants. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept: the answer-entity shape is taught under
EventQuestionAnswerConfiguration. This is the stripped-down member of the three: it declares no indexes at all, and it is the only one of the three that does not importMMCA.Common.Infrastructure.Persistence.Configuration, because it never needsHasSoftDeleteFilter(). - Walkthrough (
:18-31): requiredSpeakerId,QuestionIdandAnswerValue(atSpeakerInvariants.AnswerValueMaxLength), thenHasOne(p => p.Speaker).WithMany(p => p.SpeakerQuestionAnswers).HasForeignKey(p => p.SpeakerId).IsRequired(). Only the conventional EF index on theSpeakerIdforeign key exists. - Why it's built this way: these rows arrive from the Sessionize import as part of a speaker payload and are read back with the speaker, never queried independently or submitted concurrently by two authors, so neither the BR-123 anti-race unique index nor an extra lookup index earns its cost here. Contrast with the event and session answer configurations, where an attendee-facing form can be double-submitted.
- Where it's used: populated by the Sessionize sync path and read as part of the speaker detail projection.
SessionAssetConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.SessionAssets·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/SessionAssets/SessionAssetConfiguration.cs:18· Level 9 · class
- What it is: the persistence map for
SessionAsset, a file or link (slides, a recording, a resource) attached to a session, with the uploading speaker recorded and its own sort order. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SessionAsset,SessionAssetInvariants,Event,Session,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept: an instance of the join/child-entity mapping template taught under
EventSpeakerConfigurationandCategoryItemConfiguration: required scalars sized from an...Invariantsclass, twoHasOne(...).WithMany()parent relationships with no inverse navigation, and filtered indexes shaped for the two read paths that actually query the table. - Walkthrough
- Required (
:26-33,:35-41,:54-55):EventId,SessionId,Kind,TitleatSessionAssetInvariants.TitleMaxLength,UrlatSessionAssetInvariants.UrlMaxLength,SortOrder. - Optional (
:43-52,:57-58):BlobName(SessionAssetInvariants.BlobNameMaxLength),ContentType(SessionAssetInvariants.ContentTypeMaxLength),SizeBytes,UploadedBySpeakerId, allIsRequired(false).BlobNameandContentTypebeing nullable is the schema admitting that not every asset is a stored blob; an asset that is an external link carries aUrland no blob. - Event and Session relationships (
:60-68): both required, bothHasOne<Event>()/HasOne<Session>()with the parameterlessWithMany(), so neitherEventnorSessionexposes an assets collection navigation. - Indexes (
:70-78): a filtered composite(SessionId, SortOrder), commented as serving the one read shape the public session page has, one session's assets in sort order (:70-74); and a filtered single-columnEventIdindex for the event-level cascade delete to select every asset of one event in one read (:76-78).
- Required (
- Why it's built this way: sizing the two indexes to the two actual consumers, the per-session ordered read and the per-event bulk delete, rather than adding a general-purpose index, follows the pattern used across the rest of this folder.
- Where it's used: exposed as
DbSet<SessionAsset> SessionAssetsonModuleApplicationDbContext(ModuleApplicationDbContext.cs:78).
[Rubric §8, Data Architecture] applies: index shape follows the two queries the table actually serves rather than a generic default.
SessionCategoryItemConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionCategoryItemConfiguration.cs:11· Level 9 · class
- What it is: the persistence map for the
SessionCategoryItemjoin entity, which tags a session with aCategoryItem(topic, level, track). - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SessionCategoryItem,Session,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept: an exact instance of the join-entity template taught under
EventSpeakerConfiguration. - Walkthrough: required
SessionId(:19-20) andCategoryItemId(:22-23);HasOne(p => p.Session).WithMany(p => p.SessionCategoryItems).HasForeignKey(p => p.SessionId).IsRequired()(:25-28);HasIndex(p => new { p.SessionId, p.CategoryItemId }).IsUnique().HasSoftDeleteFilter()(:30-32). - Where it's used: the session browse and filter queries resolve topic tags through these rows.
SessionConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionConfiguration.cs:12· Level 9 · class
- What it is: the persistence map for
Session, the busiest entity in the Conference schema: talk metadata, schedule window, status flags, links, and the relationships to its event and room. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,Session,Event,Room,SessionInvariants,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore(forDeleteBehavior). - Concept introduced,
DeleteBehavior.Restrictas a schedule-integrity guard. The optional room relationship (:83-87) ends in.OnDelete(DeleteBehavior.Restrict). Under EF's default for an optional relationship the FK would be set to null on delete, silently unscheduling every talk in the room;Restrictmakes the database refuse the delete instead, forcing the organizer to move the sessions first.[Rubric §8, Data Architecture]assesses whether referential actions match the business meaning of the relationship rather than the framework default. - Concept reinforced, navigation configured on one side only. Both relationships here use the parameterless
WithMany()(:73,:84), meaning there is no inverse collection navigation onEventorRoomfor sessions. Sessions are a large collection queried with paging and filters, so exposing them as an aggregate navigation would invite accidental full loads; the read paths go through explicit queries instead. - Walkthrough
- Required (
:20-22,:38-48,:77-78):TitleatSessionInvariants.TitleMaxLength; four booleans,IsInformed,IsConfirmed,IsServiceSession,IsPlenumSession; and theEventIdscalar. - Optional (
:24-36,:50-64,:88-89):Description,StartsAt,EndsAt,Status,LiveUrl,RecordingUrl,AccessibilityInfo,ResourceLinks,RoomId. ThatStartsAt,EndsAtandRoomIdare all nullable is the schema admitting that a session exists as an accepted talk long before it is scheduled. Statusis a plain string (:34-36) capped atSessionInvariants.StatusMaxLength, not an enum with a conversion (the entity declares it asstring?atMMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sessions/Session.cs:37), so adding a status value needs no migration.Durationis a stored computed column, not an ignored derived property (:74-75):builder.Property(p => p.Duration).HasComputedColumnSql("DATEDIFF(minute, [StartsAt], [EndsAt])", stored: true). The comment above it (:66-73) explains why: the sessions grid sorts onDuration, and anORDER BYneeds a column, dynamic LINQ cannot express a date difference and the provider does not translateDateTimesubtraction client-side, so the value has to live in the database.stored: truekeeps the two representations from drifting, the column is a function ofStartsAt/EndsAt, so no writer can set it to anything else; EF infersValueGeneratedOnAddOrUpdatefrom a computed column, so the client value is never sent and the stored value is read back afterSaveChanges. The property stays nullable (int?), so there is noIsRequired()call: a session without both bounds has no duration, andDATEDIFFreturnsNULLfor it.- Event relationship (
:80-83) required, plusHasIndex(p => p.EventId).HasSoftDeleteFilter()(:85-86), a non-unique filtered lookup index for "all live sessions of this event", the single hottest read in the app. - Room relationship (
:91-95) optional, with theRestrictbehaviour described above.
- Required (
- Why it's built this way: the required or optional split mirrors the real conference workflow (accept first, schedule later), and the two relationship decisions, no inverse navigation and restricted room deletes, both trade a little convenience for predictable performance and predictable schedule integrity. Moving
Durationfrom an ignored computed property to a stored computed column is the same trade applied to sorting: the database, not the client, now owns the one representation of it. - Where it's used:
Sessionis the owning aggregate forSessionSpeakerConfiguration,SessionCategoryItemConfigurationandSessionQuestionAnswerConfiguration, and the scalar target ofSessionAiScoreConfiguration.
SessionQuestionAnswerConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionQuestionAnswerConfiguration.cs:11· Level 9 · class
- What it is: the persistence map for
SessionQuestionAnswer, one attendee's answer to one session-scoped feedbackQuestion. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SessionQuestionAnswer,Session,SessionInvariants,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept introduced, when a filtered index is the wrong index. This configuration keeps a plain, unfiltered
HasIndex(p => p.SessionId)(:37) alongside the filtered composite, and the comment (:34-36) gives a reason worth internalising: the Sessionize sync reads this table bySessionIdwith the query filters OFF, and a filtered index cannot serve a query that does not carry the filter's predicate. Soft-delete-filtered indexes are the default choice for application reads, but any code path that deliberately bypasses the global query filter needs an unfiltered index or it falls back to a scan. CompareEventQuestionAnswerConfiguration, whose equivalent parent index is filtered, because nothing reads event answers with the filters off.[Rubric §12, Performance and Scalability]assesses whether indexes match the queries that actually run, including the maintenance ones. - Walkthrough: required
SessionId,QuestionIdandAnswerValueatSessionInvariants.AnswerValueMaxLength(:19-27); required parent relationshipHasOne(p => p.Session).WithMany(p => p.SessionQuestionAnswers).HasForeignKey(p => p.SessionId)(:29-32); the deliberately unfilteredSessionIdindex (:37); and the BR-123 indexHasIndex(p => new { p.SessionId, p.QuestionId, p.CreatedBy }).IsUnique().HasSoftDeleteFilter()(:43-45), whose comment (:39-42) repeats the concurrency rationale taught underEventQuestionAnswerConfiguration: the in-memory upsert can be raced, the database refuses the loser, and the sharedDbUpdateExceptionhandler renders it as a 409. - Why it's built this way: it carries both index shapes because it serves two different consumers, an attendee-facing form that must not double-submit, and an import job that reads across soft-deleted rows.
- Where it's used: written by the session-feedback command handlers, read by the feedback aggregation queries and by the Sessionize sync.
SessionSpeakerConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Sessions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionSpeakerConfiguration.cs:11· Level 9 · class
- What it is: the persistence map for the
SessionSpeakerjoin entity, which records who is presenting a session. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,SessionSpeaker,Session,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept: an exact instance of the join-entity template taught under
EventSpeakerConfiguration. - Walkthrough: required
SessionId(:19-20) andSpeakerId(:22-23);HasOne(p => p.Session).WithMany(p => p.SessionSpeakers).HasForeignKey(p => p.SessionId).IsRequired()(:25-28);HasIndex(p => new { p.SessionId, p.SpeakerId }).IsUnique().HasSoftDeleteFilter()(:30-32). The composite unique index is what stops the same speaker being added twice to one session, while still allowing a remove-then-re-add. - Where it's used: joined by the session detail and speaker detail read paths to resolve a session's presenters.
SponsorConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Sponsors·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sponsors/SponsorConfiguration.cs:11· Level 9 · class
- What it is: the EF Core persistence map for the
Sponsoraggregate: eleven column facets, an enum-to-int conversion for the sponsorship tier, the required relationship to the owningEvent, and one non-unique filtered lookup index. It is the newest member of the seventeen-class configuration family in this folder (seventeen*Configuration.csfiles today). - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>(base,:12),Sponsor,Event,SponsorInvariants(everyHasMaxLengthargument),SponsorTier(indirectly, through theTierproperty it converts), andIndexBuilderExtensionsforHasSoftDeleteFilter()(:68, imported at:3). External:Microsoft.EntityFrameworkCore.Metadata.Builders.EntityTypeBuilder<T>(:1). - Concept: the shared shape of this family, an
internal sealedclass over the SQL Server base whoseConfigureopens withbase.Configure(builder)and therefore inherits table name, schema, key and value generation, is taught once underCategoryItemConfiguration. That section also explains why the length constants come from an...Invariantsclass rather than from literals, and whatHasSoftDeleteFilter()does. Only the two ideas below are new here. - Concept introduced, storing an enum as its underlying
inton purpose.Tieris aSponsorTier, a four-member enum whose numeric values are deliberately the display order:Platinum = 0,Gold = 1,Silver = 2,Community = 3(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sponsors/SponsorTier.cs:15-24, with the ordering rationale in the doc comment at:4-6and the CA1008 zero-member note at:9-10). The configuration spells the storage out,builder.Property(p => p.Tier).HasConversion<int>().IsRequired()(:25-27), and the comment above it (:23-24) gives both halves of the reason: the tier ordering stays a plain integer column sort, and adding a package later does not rewrite existing rows. The second half is the part worth internalizing. Appending a new member at the high end of the enum leaves every stored row valid, while re-numbering to slot a package into the middle would require a data migration. The shipped column isTier int NOT NULL(MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference/Migrations/20260812202047_AddSponsors.cs:22). This is the onlyHasConversion<int>()in the Conference configuration folder; the one other converter in the family,SpeakerConfiguration'sNullableEmailValueConverter(SpeakerConfiguration.cs:43), converts a value object, not an enum.[Rubric §8, Data Architecture]assesses whether column types are a deliberate choice rather than a convention default: writing the conversion at the call site pins the storage shape where a reader of the mapping will see it, instead of leaving it implied by provider convention two layers away. - Concept reinforced, a root that references another root by id.
Sponsoris an aggregate root in its own right (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/Sponsor.cs:18,sealed class Sponsor : AuditableAggregateRootEntity<SponsorIdentifierType>), not a child of theEventaggregate. The mapping shows that boundary directly: the relationship is declaredHasOne(p => p.Event).WithMany().HasForeignKey(p => p.EventId).IsRequired()(:62-65), andWithMany()takes no navigation expression becauseEventexposes noSponsorscollection at all (Event.csmentions sponsorship only as the scalarSponsorshipPacketUrlat:62). So a sponsor knows its event, an event does not enumerate its sponsors, and nothing can load a sponsor set by walking the event aggregate: reads go through a filter onEventId.[Rubric §4, DDD]assesses whether aggregate boundaries are drawn and then respected in the persistence layer; a one-way navigation is how that boundary gets enforced by the mapping rather than left to discipline. ContrastSessionAiScoreConfiguration, which goes one step further and maps no relationship at all, andSessionSpeakerConfiguration, whoseWithMany(p => p.SessionSpeakers)names both ends because that row genuinely belongs to the session aggregate. - Walkthrough
- Class declaration (
:11-12):internal sealed class SponsorConfiguration : EntityTypeConfigurationSQLServer<Sponsor, SponsorIdentifierType>, the second type argument being the module's identifier alias. base.Configure(builder)(:17): tableSponsor, schemaConference(both derived, and both visible in the shipped migration at20260812202047_AddSponsors.cs:15-16), key onId, identity value generation.- Required scalars (
:19-21,:49-53,:59-60):NameatSponsorInvariants.NameMaxLength(200,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Sponsors/SponsorInvariants.cs:13);SortandIsExhibitorrequired with no configured default;EventIdrequired. - The tier conversion (
:25-27): described above. - Optional presentation columns (
:29-47,:55-57):LogoUrl,Description,WebsiteUrlandLinkedInUrleachIsRequired(false)at 2000 characters (SponsorInvariants.cs:16,:19,:22,:25);TwitterHandleat 100 (:28);BoothNumberat 50 (:31). All seven widths read a constant, so this configuration contains no literal lengths. - Event relationship (
:62-65): the one-way requiredHasOne/WithMany()pair described above. The shipped foreign key isFK_Sponsor_Event_EventIdwithReferentialAction.Cascade(20260812202047_AddSponsors.cs:42-48). - Lookup index (
:67-68):builder.HasIndex(p => p.EventId).HasSoftDeleteFilter(). It is not unique, soSoftDeleteUniqueIndexConventionwould never have touched it and the explicit call is the only way the predicate gets applied; the migration confirms the shipped shape,IX_Sponsor_EventIdwithfilter: "[IsDeleted] = 0"(20260812202047_AddSponsors.cs:51-56). It is aimed at exactly one query: the public sponsor strip fetches a page withfilters["EventId"] = ("equals", ...)andsortColumn: "Sort"(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/Pages/Public/Sponsors/PublicSponsorList.razor.cs:76-99), an equality predicate onEventIdintersected with the global soft-delete filter, and the filtered index covers both halves.[Rubric §12, Performance and Scalability]assesses whether index shape follows the queries that actually run. - What is absent from this file and still ends up in the table:
IsDeleted,CreatedOn/CreatedBy,LastModifiedOn/LastModifiedByand therowversionconcurrency token are all in the shipped table (20260812202047_AddSponsors.cs:32-37) without appearing anywhere inConfigure. They come fromApplicationDbContextand the entity base, which makes this the cleanest single illustration in the chapter of the division of labour taught underCategoryItemConfiguration: a configuration class owns only this entity's columns, relationships and indexes.
- Class declaration (
- Why it's built this way: sponsors are per-event data with a public, ordered presentation, so the mapping optimizes for the two things the public page does, filter by event and sort within a tier, and for schema stability as sponsorship packages change. Keeping every width on
SponsorInvariantsmeans the column, the domain guard (EnsureNameIsValidatSponsorInvariants.cs:39,EnsureLogoUrlIsValidat:51,EnsureBoothNumberIsValidat:63) and the Application-layer request validators cannot drift apart, which is what[Rubric §15, Best Practices & Code Quality]looks for. Confining all of it to one Infrastructure class keeps theSponsorentity free of EF attributes, the Clean Architecture dependency rule this whole folder exists to serve. - Where it's used: applied when the concrete
SQLServerDbContextfor theADC_Conferencedatabase builds its model by scanning the module assembly, exactly like its sixteen siblings; snapshotted by the Conference migrations project (MMCA.ADC/Source/Hosting/MMCA.ADC.Migrations.SqlServer.Conference, table created by20260812202047_AddSponsors.cs). Rows are written byConferenceModuleDbSeeder's sample-data path and by the sponsor command handlers, and read bySponsorServiceforPublicSponsorList.ModuleApplicationDbContextdoes declare aSponsorsDbSet, but as that section explains, theDbSetlist is an index, not the source of the model. - Caveats / not-in-source: (1) There is no unique index on
(EventId, Name), or onNameat all. The seeder's idempotency check probess => s.Name == name(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/DbContexts/Seeding/ConferenceModuleDbSeeder.cs:398-400), so uniqueness of sponsor names is an application-level convention with no database backstop, unlike the room-name and session-speaker cases elsewhere in this folder. (2) TheTiercolumn carries no check constraint, so a value outside0..3would be storable by anything that bypasses the domain factory; the enum is enforced in the CLR type, not in SQL. (3)BoothNumberis nullable and independent ofIsExhibitor: the domain deliberately accepts a booth number on a non-exhibitor (SponsorInvariants.cs:57-58, "the flag drives display, it does not reject stored data"), and the mapping adds no constraint tying the two together. (4) TheCascadedelete on the event foreign key is not overridden here; because the codebase soft-deletes rather than hard-deletes, whether that cascade ever executes in a deployed database is not determinable from source. (5) The grouping by tier that the public page renders happens in memory after the fetch (PublicSponsorList.razor.cs:106-110), not as a SQLORDER BY Tier, so the int conversion enables a cheap column sort that today's read path does not yet ask the database to perform.
PartnerConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Partners·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Partners/PartnerConfiguration.cs:11· Level 9 · class
- What it is: the EF Core persistence map for the
Partnerentity: a community organization or special partner (venue host and similar) attached to an event, shown on the public partners band alongside sponsors. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>(base,:11-12),Partner,PartnerInvariants(everyHasMaxLengthargument),Event, andIndexBuilderExtensionsforHasSoftDeleteFilter()(:53). External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept: the same family shape taught under
CategoryItemConfigurationand applied again bySponsorConfiguration: aninternal sealedclass over the SQL Server base whoseConfigureopens withbase.Configure(builder)and sizes every string from an...Invariantsclass. - Concept reinforced, storing an enum as its underlying
int.PartnerTypeis storedHasConversion<int>().IsRequired()(:25-27), and the comment above it (:23-24) gives the same reason asSponsorConfiguration'sTiercolumn: the group ordering stays a plain column sort and adding a partner type later does not rewrite existing rows.PartnerTypeis a two-member enum,Community = 0andSpecial = 1(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Partners/PartnerType.cs:15-18), withCommunitydeliberately the zero value because it carries no commercial weight, so an omitted type landing on it is harmless (PartnerType.cs:8-10). - Walkthrough
- Class declaration (
:11-12):internal sealed class PartnerConfiguration : EntityTypeConfigurationSQLServer<Partner, PartnerIdentifierType>. base.Configure(builder)(:17): table, schema, key and value generation, as in every configuration in this folder.- Required scalars (
:19-21,:41-45):NameatPartnerInvariants.NameMaxLength(200,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Partners/PartnerInvariants.cs:16);Sort;EventId. - The type conversion (
:25-27): described above. - Optional presentation columns (
:29-39):LogoUrl(PartnerInvariants.LogoUrlMaxLength, 2000,PartnerInvariants.cs:19),Description(DescriptionMaxLength, 2000,:22),WebsiteUrl(WebsiteUrlMaxLength, 2000,:25), allIsRequired(false). - Event relationship (
:47-50): requiredHasOne(p => p.Event).WithMany().HasForeignKey(p => p.EventId).IsRequired(), the parameterlessWithMany()again meaningEventexposes noPartnerscollection navigation, the same one-way boundarySponsorConfigurationdraws forSponsor. - Lookup index (
:52-53):builder.HasIndex(p => p.EventId).HasSoftDeleteFilter(), not unique, aimed at the public partners-band query filtered by event.
- Class declaration (
- Why it's built this way:
PartnermirrorsSponsor's shape deliberately, per-event data with a public, ordered presentation, so the mapping makes the same three choices: anint-backed type column that tolerates future values, a one-way required relationship toEvent, and a single filtered index sized to the one read the public band performs. - Where it's used: applied when the concrete
SQLServerDbContextbuilds its model by scanning the module assembly, one of nineteen configurations now in this folder.Partnergets aPartnersDbSetonModuleApplicationDbContext(ModuleApplicationDbContext.cs:72). Rows are written byConferenceModuleDbSeeder'sSeedPartnersAsync(ConferenceModuleDbSeeder.cs:440-483) and by the partner command handlers. - Caveats / not-in-source: (1) There is no unique index on
(EventId, Name)or onNamealone; the seeder's idempotency check probesp => p.Name == name(ConferenceModuleDbSeeder.cs:456-459), so name uniqueness is an application-level convention with no database backstop, the same gapSponsorConfigurationcarries. (2)PartnerTypecarries no check constraint, so a value outside0..1would be storable by anything that bypasses the domain factory.
EventConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Events/EventConfiguration.cs:12· Level 14 · class
- What it is: the persistence map for
Event, the top aggregate of the Conference module (the conference itself: dates, venue, publication state, Sessionize linkage). - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,Event,EventInvariants,QuestionModerationDefault,NullableEmailValueConverter. External:Microsoft.EntityFrameworkCore. - Concept reinforced, the filtered unique index.
HasIndex(p => p.SessionizeCode).IsUnique().HasFilter("[SessionizeCode] IS NOT NULL")(:45-47) is both filtered and unique. A filtered index only covers the rows matching its predicate, so this one indexes just the events that carry a Sessionize code, which is the population the import path looks up by, and it costs nothing for the events with a null code. Uniqueness applies among those non-null codes only, so any number of events may have no code. The comment above it (:42-44) gives the reason: the code is the event's Sessionize identity, and the unique constraint is what lets the seeder tolerate two replicas seeding the same event at once, because the slower insert fails on this index instead of creating a duplicate event.[Rubric §12, Performance and Scalability]assesses whether indexes are chosen for the actual query shape rather than sprayed across columns: this is a narrow index sized to one lookup that doubles as a concurrency guard. - Concept reinforced, a value object mapped by a converter rather than an owned type.
OrganizerContactEmailis anEmailvalue object on the entity, but the column is still the same nullablenvarcharit was when the property was a string:HasConversion(new NullableEmailValueConverter())(:65) turns the object into its text on the way down and back on the way up. The comment above it (:61-63) states the consequence directly: converter, notOwnsOne, so the store type and length are unchanged and no migration is needed. That is the cheap half of ADR-068, a type-safe domain property bought without touching the schema.[Rubric §8, Data Architecture]assesses whether the storage shape is chosen independently of the domain shape. - Walkthrough
- Required core (
:20-22,:28-36):Name(EventInvariants.NameMaxLength),StartDate,EndDate, andTimeZone(EventInvariants.TimeZoneMaxLength). Storing the IANA time-zone id as a column rather than baking a UTC offset into the dates is what lets the schedule render correctly across DST. - Optional descriptive, venue and link columns (
:24-26,:49-75):Description,VenueAddress,VenueMapUrl,WiFiInfo,OrganizerContactEmail,SponsorshipPacketUrlandTicketingUrl, eachIsRequired(false)with its own invariant-sourced max length, andOrganizerContactEmailadditionally carrying the converter above (:64-67). - Sessionize linkage (
:38-47,:84-88):SessionizeCodeoptional plus the filtered unique index above;LastSessionizeRefreshOn/LastSessionizeRefreshByare optional audit-style columns recording the last import run.[Rubric §13, Observability and Operability]assesses whether the system records the provenance of imported data: these two columns answer "when was this event last synced, and by whom" from the row itself. - State flags (
:77-82):IsPublishedrequired;QuestionModerationDefaultrequired, with the comment (:80) noting it is stored as anintthrough EF's default enum conversion and thatPending(0) is the safe default per BR-233. There is noHasConversioncall, EF's default enum-to-int mapping is used as-is, and the enum really does declarePending = 0(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/QuestionModerationDefault.cs:10), so the safe default is also the zero value in the database. - Navigation access mode (
:96-101):Rooms,EventSpeakersandEventQuestionAnswersare each set toUsePropertyAccessMode(PropertyAccessMode.Field)because every one of them is a getter over a private list returned asAsReadOnly(). The comment (:90-95) is explicit that convention already infers field access here and that stating it is insurance: a later change to the property cannot silently turn materialization into a no-op. It is an access-mode declaration only, the relationships themselves stay configured from the child side.
- Required core (
- Why it's built this way: everything the organizer may not know at creation time is nullable, so an event can be created early and enriched later without a two-phase workflow; only the four facts that make an event an event are required.
- Where it's used:
Eventis the FK target ofRoomConfiguration,SessionConfiguration,EventSpeakerConfiguration,EventQuestionAnswerConfiguration,ActivityConfigurationandSponsorConfiguration.
EventQuestionAnswerConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Events/EventQuestionAnswerConfiguration.cs:11· Level 8 · class
- What it is: the persistence map for
EventQuestionAnswer, one attendee's answer to one event-scopedQuestion. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,EventQuestionAnswer,Event,EventInvariants,IndexBuilderExtensions(HasSoftDeleteFilter). External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept introduced,
HasSoftDeleteFilter()and the database as the concurrency backstop.HasSoftDeleteFilter()(IndexBuilderExtensions.cs:52-66) replaces a hand-typedHasFilter("[IsDeleted] = 0"). It builds the predicate throughSoftDeleteFilterSqlfrom the live model (:56), so a renamed soft-delete column follows automatically and the identifier quoting comes from the engine instead of a SQL-Server-shaped literal. Itsengineparameter defaults toDataSource.SQLServer(:51), which is exactly what the…SQLServerbase already implies. On a unique index the call is technically redundant withSoftDeleteUniqueIndexConvention, which would apply the same predicate at model finalizing; writing it explicitly keeps the intent readable at the call site, and because the convention skips any index that already declares a filter (SoftDeleteUniqueIndexConvention.cs:54) the two can never disagree. On a non-unique index like theEventIdlookup here, the convention deliberately does nothing, so the explicit call is the only way to get the filter.- The
(EventId, QuestionId, CreatedBy)unique index (:42-44) is a race backstop, and the comment (:38-41) is unusually candid about why: the application-level upsert only inspects the in-memory collection, so two concurrent submits can both take the create branch. The database refuses the second one, and the sharedDbUpdateExceptionhandler turns the violation into a 409 for the client.[Rubric §8, Data Architecture]assesses whether invariants that matter are enforced where they cannot be raced, and[Rubric §15, Best Practices and Code Quality]assesses whether known limitations are documented at the point of the compensating control rather than left for the next reader to discover.
- Walkthrough: required
EventIdandQuestionIdscalars (:19-23); requiredAnswerValueatEventInvariants.AnswerValueMaxLength(:25-27); required parent relationshipHasOne(p => p.Event).WithMany(p => p.EventQuestionAnswers).HasForeignKey(p => p.EventId)(:29-32); soft-delete-filtered lookup index onEventId(:35-36); the BR-123 filtered unique index (:42-44). - Why it's built this way:
CreatedByis part of the uniqueness tuple, so "one live answer per question" is scoped per author, not globally, which is what a per-attendee feedback form needs. - Where it's used: written by the Conference event-feedback command handlers; read by the feedback queries. Compare its sibling
SessionQuestionAnswerConfiguration, which carries the same BR-123 index but treats its parent lookup index differently for a specific reason.
EventSpeakerConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Events/EventSpeakerConfiguration.cs:11· Level 8 · class
- What it is: the persistence map for the
EventSpeakerjoin entity, which records that a speaker is part of an event's line-up. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,EventSpeaker,Event,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept introduced, the join-entity template. Four configurations in this folder are structurally identical, and this is the first: both FK scalars required, one
HasOne(...).WithMany(...)relationship to the owning aggregate only (the side whose collection navigation the join belongs to), and a soft-delete-filtered composite unique index on the two FKs. The other side of the pair is deliberately not configured as a relationship, which keeps the entity a one-way child of a single aggregate and matches the DDD rule that an aggregate owns its children.[Rubric §4, DDD]assesses aggregate boundary discipline;[Rubric §8, Data Architecture]assesses the uniqueness guarantee. The soft-delete filter on the unique index is what makes a delete-then-re-add cycle legal: a plain unique index would let a soft-deleted association keep occupying its slot forever, so an organizer could never re-add a speaker they had removed. - Walkthrough: required
EventId(:19-20) andSpeakerId(:22-23);HasOne(p => p.Event).WithMany(p => p.EventSpeakers).HasForeignKey(p => p.EventId).IsRequired()(:25-28);HasIndex(p => new { p.EventId, p.SpeakerId }).IsUnique().HasSoftDeleteFilter()(:30-32). - Where it's used: the same template appears in
SessionSpeakerConfiguration,SessionCategoryItemConfigurationandSpeakerCategoryItemConfiguration.
| Type | File:Line | Owning aggregate | Unique index |
|---|---|---|---|
EventSpeakerConfiguration |
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Events/EventSpeakerConfiguration.cs:11 |
Event |
(EventId, SpeakerId) |
SessionSpeakerConfiguration |
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionSpeakerConfiguration.cs:11 |
Session |
(SessionId, SpeakerId) |
SessionCategoryItemConfiguration |
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Sessions/SessionCategoryItemConfiguration.cs:11 |
Session |
(SessionId, CategoryItemId) |
SpeakerCategoryItemConfiguration |
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Speakers/SpeakerCategoryItemConfiguration.cs:11 |
Speaker |
(SpeakerId, CategoryItemId) |
QuestionConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Questions·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Questions/QuestionConfiguration.cs:10· Level 8 · class
- What it is: the persistence map for
Question, the definition of a feedback question (its text, what it attaches to, how it renders, and where it came from). - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,Question,QuestionInvariants. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept: the shared shape is taught under
CategoryItemConfiguration. What is worth noticing here is that this is the flattest configuration in the folder: six required properties, no relationships and no indexes at all. - Walkthrough (
:18-38): all six columns areIsRequired().QuestionText,QuestionEntity,QuestionTypeandQuestionSourceeach take their length fromQuestionInvariants;SortandIsRequired(the boolean, not the fluent call) are plain required scalars.QuestionEntityandQuestionTypeare declared asstringon the entity (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Domain/Questions/Question.cs:20,:23), not enums, so adding a question type or a new attachable entity needs no migration and no enum-to-string conversion. - Why it's built this way: questions are attached to events, sessions and speakers by the three
…QuestionAnswerentities, and those answers carry a plainQuestionIdscalar rather than a navigation, soQuestionitself needs no relationship configuration. Modelling the discriminators as strings keeps the question catalogue extensible from data rather than from code. - Where it's used: referenced by
QuestionIdfromEventQuestionAnswerConfiguration,SessionQuestionAnswerConfigurationandSpeakerQuestionAnswerConfiguration.
RoomConfiguration
MMCA.ADC.Conference.Infrastructure ·
MMCA.ADC.Conference.Infrastructure.Persistence.EntityConfiguration.Events·MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Infrastructure/Persistence/EntityConfiguration/Events/RoomConfiguration.cs:11· Level 8 · class
- What it is: the persistence map for
Room, a physical room belonging to anEvent. - Depends on: first-party:
EntityTypeConfigurationSQLServer<TEntity, TIdentifierType>,Room,Event,EventInvariants,IndexBuilderExtensions. External:Microsoft.EntityFrameworkCore.Metadata.Builders. - Concept introduced, re-declaring an index EF would otherwise drop. The explicit
builder.HasIndex(p => p.EventId)(:48) looks redundant next to the(EventId, Name)composite below it, and the comment (:46-47) says exactly why it is not: EF removes the conventional foreign-key index as redundant once a composite index leads with the same column, but the composite is filtered, and the plain FK lookups still want an unfiltered index. This is a good example of a mapping decision that only makes sense once you know EF's own de-duplication rule; without the comment the line reads as a mistake.[Rubric §12, Performance and Scalability]assesses whether index choices survive framework conventions rather than being silently optimized away. - Walkthrough
- Required (
:19-24):NameatEventInvariants.RoomNameMaxLength, andSort. - Optional (
:26-39):Capacity(a nullable scalar with no length), plusFloor,LocationandAccessibilityInfo, each with an invariant-sourced max length.AccessibilityInfobeing a first-class room column, not a note bolted onto the description, is the schema-level half of ADC's accessibility commitment.[Rubric §21, Accessibility]assesses whether accessibility is designed into the data rather than added at the view. - Parent relationship (
:41-44): requiredHasOne(p => p.Event).WithMany(p => p.Rooms).HasForeignKey(p => p.EventId). - Indexes (
:48,:52-54): the re-declared plainEventIdindex, thenHasIndex(p => new { p.EventId, p.Name }).IsUnique().HasSoftDeleteFilter(). The comment (:50-51) states its purpose plainly: it backstops the aggregate's duplicate-room-name invariant, and the soft-delete filter means a deleted room never blocks reusing its name.
- Required (
- Why it's built this way: the domain already refuses a duplicate room name inside the
Eventaggregate; the filtered unique index is the database-side guarantee for the concurrent case the in-memory check cannot see, the same defence-in-depth reasoning as BR-123 inEventQuestionAnswerConfiguration. - Where it's used:
Roomis the optional FK target ofSessionConfiguration, which restricts deletes against it.
⬅ ADC Conference - Application & Use Cases • Index • ADC Conference - API, gRPC Contracts & Service Host ➡