Architecture Decision Record
ADR-059: The IModule Contract and Reflection-Based Module Composition
Status
Accepted (2026-07-28; revised 2026-08-14). Revised 2026-10-07: anchors refreshed after the v1.233.0 release.
Revised 2026-10-19: ADC Conference and Engagement gain module conformance tests, so seven of the eight modules are checked (only Helpdesk Tickets is not).
Revised 2026-10-21: ADC ConferenceModule registers three stubs (adding the IUserConferenceExportService export stub), DisabledEventLiveValidationService fails open across five members, the empty-export stub list names the Conference and Store Catalog export stubs, and host anchors are refreshed.
Revised 2026-10-26: service-host anchors refreshed after the host Program.cs files moved (no behavior change).
Revised 2026-10-28: the Trade-offs list of undeclared cross-module gRPC edges grows from two to five consuming modules (ADC Engagement, Notification and Identity join ADC Conference and Store Identity), the ADR-008 reading is corrected (only ModuleLoader is its Context; the Disabled* stubs belong to its Strangler Fig step), and anchors are refreshed.
Context
The framework's headline claim is that an application is built as a modular monolith and later
extracted into services without rewriting business logic. ADR-008 states the extracted shape in one
sentence, "Each service is the monolith with one module enabled"
(Website/docs-src/adr/008-service-extraction-topology.md:51). It treats ModuleLoader as
pre-existing context before deciding the extraction topology (:30), and relies on the
Disabled* stubs in its Strangler Fig extraction step, where the combined host keeps running with
the extracted module turned off (:57-59). ADR-006 decides the database axis
of the same split. Neither records the composition model itself: how a module declares itself, how a
host finds it, in what order registrations run, what "disabled" does to the container, and what a
dependent resolves when its peer is not in the process. That mechanism is what makes the ADR-008
sentence true, and it had no decision record of its own. This ADR is that record.
Composition has to answer for the monolith case as well as the extracted one. MMCA.Helpdesk runs a single module in a single host; Store and ADC run one module per service host today, and the same module code ran in a combined host before extraction. One contract has to cover all three without the module code knowing which shape it is running in.
Decision
Make IModule the single composition contract, discover implementations by reflection, register
them in topological dependency order, and represent a disabled module by stub registrations
rather than by absence.
IModuleis five members, three of which have defaults.NameandRegister(IServiceCollection, IConfigurationBuilder, ApplicationSettings)are the only members a module must supply;Dependenciesdefaults to an empty list,RequiresDependenciestofalse, andRegisterDisabledStubs(IServiceCollection)to a no-op (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/IModule.cs:7,12,17,23,28,34). A leaf module is therefore two members:TicketsModuleimplements exactlyNameandRegister(MMCA.Helpdesk/Source/Modules/Tickets/MMCA.Helpdesk.Tickets.API/TicketsModule.cs:13-19).- Discovery is a reflection scan over the assemblies the host names.
moduleAssembliesis a required parameter ofDiscoverAndRegister(MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:59-65), with the reason written onto the parameter itself (:49-54): an ambient scan sees only assemblies already loaded, so a module assembly that is referenced but not yet touched by any code path would be silently absent from discovery.ModuleLoaderscans what it is given (ModuleLoader.cs:74-95), keeps every concrete non-abstract, non-interfaceIModuletype and instantiates each throughActivator.CreateInstance(ModuleLoader.cs:97-100), and does the same forIModuleSeeder, keyed case-insensitively byModuleName(ModuleLoader.cs:102-105,MMCA.Common/Source/Core/MMCA.Common.Application/Modules/IModuleSeeder.cs:8-19). A scan failure is not fatal but logs at Error: aReflectionTypeLoadExceptionfromGetTypes()keeps the types that did load, so the assembly's loadable modules still register (ModuleLoader.cs:81-88,352-353), and any other exception drops that assembly (ModuleLoader.cs:89-93,349-350); both paths are pinned byModuleLoaderTests.cs:223-255. - A host names one marker assembly per module it runs.
AddModuleHosttakes that list as its first parameter and hands it to the loader it builds (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/ModuleHostExtensions.cs:51-53,84-90), which is how the service hosts pass it: Store (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:131-133,MMCA.Store.Identity.Service/Program.cs:126-128,MMCA.Store.Sales.Service/Program.cs:139-141) and ADC (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:273-275,MMCA.ADC.Conference.Service/Program.cs:379-381,MMCA.ADC.Engagement.Service/Program.cs:220-222,MMCA.ADC.Notification.Service/Program.cs:202-204) each name the single assembly of the one module that host enables, so an extracted service's scan surface equals its own module. Helpdesk callsDiscoverAndRegisterwith its one module assembly inside aRegisterstep of itsAddMmcaApplicationPipelinecall (MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:125-133), and the unit tests name their own assembly the same way (MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Modules/ModuleLoaderTests.cs:42-49). - Registration order is a Kahn topological sort over the declared names. The loader sorts before
it registers anything (
ModuleLoader.cs:108); the sort builds an in-degree map plus a reverse adjacency list, seeds a queue with the zero-in-degree modules, and drains it (ModuleLoader.cs:282-332), so a dependency's DI registrations are always in the container before a dependent'sRegisterruns. A declared name that no discovered module supplies is skipped while building the graph and never blocks the sort (ModuleLoader.cs:297-298). - A cycle is a startup failure that names the cycle. If fewer modules come out of the sort than
went in, the remainder is circular and the loader throws
InvalidOperationExceptionlisting them (ModuleLoader.cs:323-329);ModuleLoaderTests.cs:71-83asserts both the message and the member names. - Enablement is configuration, and absence means off.
ModulesSettingsbinds theModulessection as a name-to-ModuleSettingsdictionary (MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ModulesSettings.cs:7,10), andIsModuleEnabledreturns true only when the key exists andEnabledis true (ModulesSettings.cs:18-19). A module missing from configuration is therefore treated as disabled, even thoughModuleSettings.Enableditself defaults totrue(MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ModuleSettings.cs:9);ModuleLoaderTests.cs:300-306pins that behavior. - Disabled means stubs, not absence. For a disabled module the loader calls
RegisterDisabledStubsand never callsRegister, snapshots the descriptors that call appended so they can be validated later, and records the name (ModuleLoader.cs:112-123). The stubs are null-object implementations of the owning module's cross-module contracts, shipped in that module's*.Sharedproject:DisabledProductVariantServiceanswersfalsefor existence checks,nullfor the SKU lookup, an empty collection for the batch existence query and an empty dictionary for the batch price and catalog-info queries (MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/Products/DisabledProductVariantService.cs:10-50);DisabledCustomerServicereturnsnull(MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Shared/Customers/DisabledCustomerService.cs:8-15);DisabledSessionBookmarkValidationServicereturnsResult.Success()plus an empty session-id collection, which deliberately skips the BR-49 / BR-91 eligibility checks (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Sessions/DisabledSessionBookmarkValidationService.cs:30-39);DisabledEventLiveValidationService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/DisabledEventLiveValidationService.cs:23-75) fails open across all five of the members it has grown to: an always-open live window for an event (:25-27) and for a session (:29-43), every sponsor reported as belonging to a published event under a default id and an empty name (:45-51), a room's own id echoed back as the current session with an empty title (:53-63), and an empty overlapping-session set, so a per-time-slot rule is skipped rather than rejecting every check-in (:65-74). The dependent's code path does not branch on "is the module here": it resolves the interface and gets a documented degraded answer. Dependenciesdeclares the graph;RequiresDependenciesdecides whether a gap is fatal. The loader computes the module's disabled dependencies, then narrows them to "unsatisfied" by removing any name the consumer listed underModules:{Module}:RemoteDependencies(ModuleLoader.cs:142-148,ModulesSettings.cs:30-32,ModuleSettings.cs:38). WithRequiresDependencies = truean unsatisfied dependency throws at startup with remediation text naming the three options (ModuleLoader.cs:150-158); with the defaultfalsethe loader logs a warning and the module runs against the stub (ModuleLoader.cs:160-163,343-344). Only the strict branch is under test:ModuleLoaderTests.cs:87-97asserts that aRequiresDependencies = truemodule with an unsatisfied disabled dependency throws, andModuleLoaderTests.cs:118-128asserts that the same strictness passes once that dependency is declared underRemoteDependencies. The defaultfalsewarn-and-continue path has no test of its own.- Per-module configuration arrives by naming convention. Immediately before
Register, the loader addsmodules.{name}.jsonand, when the host passes an environment name,modules.{name}.{environment}.json, both optional and reload-on-change (ModuleLoader.cs:182-189), so a module can carry its own configuration file without a host edit. - Composition also shapes the HTTP surface, seeding and health.
AddAPI(modulesSettings)installsModuleControllerFeatureProvider(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:45,69-73), which removes from MVC discovery any controller whose assembly name or namespace contains a.{ModuleName}.token for a disabled module, so those endpoints are never mapped instead of mapping and then failing with a 500 (MMCA.Common/Source/Presentation/MMCA.Common.API/ModuleControllerFeatureProvider.cs:33-53,60-82). Seeders run only for enabled modules and in registration order (ModuleLoader.cs:129-132,266-272), invoked from startup database initialization (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:51-54,137).AddModuleHealthCheckspublishes onemodule-{Name}check per module, Healthy when enabled and Degraded when disabled (DependencyInjection.cs:217-239). - A remote-dependency validator exists but is not wired.
ValidateRemoteDependenciesre-resolves every service type a disabled dependency's stub registered, throwing when it no longer resolves and warning when it still resolves to the stub type (ModuleLoader.cs:212-257). It is exercised only by unit tests (ModuleLoaderTests.cs:132-178); no host calls it today, so a forgotten gRPC client registration is still a silent stub at the first request rather than a startup failure.
The module inventory is small and explicit. MMCA.Store has three: CatalogModule (leaf, stubs
IProductVariantService and IUserCatalogExportService,
MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/CatalogModule.cs:15-48, the stubs at
:31-32), IdentityModule
(leaf, stubs ICustomerService,
MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/IdentityModule.cs:16-47, the stub at
:27-28) and SalesModule, which declares ["Catalog", "Identity"] with
RequiresDependencies = true and publishes one cross-module contract of its own,
IUserSalesExportService stubbed by DisabledUserSalesExportService
(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/SalesModule.cs:24-99, the stub registration at
:46-47,
MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/Exports/DisabledUserSalesExportService.cs:7-12).
Store has two data-subject export edges into Identity, Sales and Catalog, which is why Store's
IdentityModule.Register adds both AddUserDataExportSection<SalesUserDataExportSection>() and
AddUserDataExportSection<CatalogUserDataExportSection>()
(MMCA.Store.Identity.API/IdentityModule.cs:40,45). MMCA.ADC has four:
IdentityModule (leaf, stubs IAttendeeQueryService,
MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModule.cs:13-25), ConferenceModule
(no declared dependencies, three stubs including the IUserConferenceExportService export contract,
MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:16-32, the stubs at
:24-26),
EngagementModule (["Conference"], strict, two stubs,
MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:14-35) and
NotificationModule (["Identity"], strict, one stub,
MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/NotificationModule.cs:15-36).
MMCA.Helpdesk has exactly one, TicketsModule: the single-module seed, a leaf that overrides
none of the three defaulted members, ships no Disabled* stub and no IModuleSeeder, and is enabled
by the one host with "Tickets": { "Enabled": true }
(MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/appsettings.json:13-15). Seeders exist for five of
the eight modules (Store Catalog, Identity, Sales; ADC Identity, Conference).
The per-service configuration is where "the monolith with one module enabled" becomes literal. Store
Catalog enables Catalog and disables both peers with no remote declarations
(MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/appsettings.json:20-24); Store Sales enables
Sales and declares ["Catalog", "Identity"] as RemoteDependencies
(MMCA.Store/Source/Services/MMCA.Store.Sales.Service/appsettings.json:27-34), then registers typed
gRPC clients for the two contracts after the loader returns
(MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:252,258-259); because the host names
only its own module assembly, the peer modules are never discovered there and their stubs are never
registered. ADC Engagement declares
["Conference"] remote (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/appsettings.json:38-46,
the list at :43) and ADC Notification declares ["Identity"] remote
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/appsettings.json:27-35, the list at :33),
while ADC Conference and ADC Identity each enable one module and declare nothing remote
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/appsettings.json:21-26,
MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/appsettings.json:30-33). Those declarations
cover only the peers a module lists in Dependencies. Every ADC host also wires at least one peer
gRPC client it declares nowhere: Conference wires Engagement's IBookmarkCountService
(MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:431), Engagement wires
Notification's live-channel client
(MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:303), Notification wires
Conference's event-live validation client
(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:242), and Identity wires the
Engagement, Notification and Conference export clients
(MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:342-344). The Trade-offs record
those undeclared edges.
Rationale
- Reflection discovery keeps hosts out of the module registration business. A host names the
assemblies and gets whatever
IModuletypes they contain: no per-module registration call, no ordering to get right, and no hand-maintained handler list to keep in step with the module (ModuleLoader.cs:97-100). Naming the assemblies is what makes the set discovery sees a property of the composition root rather than of whatever the runtime happened to load (ModuleLoader.cs:49-54). - Topological order replaces registration luck. Cross-module registration depends on ordering,
and declaring
Dependenciesmakes that ordering explicit and machine-checked instead of implicit in the order the host happens to call things (ModuleLoader.cs:108,282-332). - Null-object stubs are what make extraction a hosting change. Because a disabled module still
puts its contract type in the container, the dependent module's Application and Domain code has no
branch for "peer not present", which is precisely why a module's non-hosting layers are identical
in-process and extracted (
ModuleLoader.cs:119,CatalogModule.cs:31-32). An extracted host, which never discovers its peers, registers a real cross-process adapter for the same contract instead (Sales.Service/Program.cs:258-259). - Two strictness levels, chosen per module. A module that genuinely cannot function without a
peer opts into
RequiresDependencies = trueand fails fast (SalesModule.cs:37,EngagementModule.cs:23,NotificationModule.cs:24); everything else tolerates a missing peer and degrades, which is the safer default for a module whose cross-module call is advisory. RemoteDependencieskeeps strictness usable after extraction. Without it, every strict module would have to be relaxed to run in its own service, losing the check in the very topology that needs it most; instead the operator states the dependency is satisfied out of process (ModuleSettings.cs:11-38,ModuleLoader.cs:146-147).- Composition lives in configuration, so one build serves N deployments. The same assemblies run
as a combined host or as a set of single-module services with no code change; the only difference
is the
Modulessection each host reads (ModulesSettings.cs:10, plus the per-serviceappsettings.jsonblocks cited above).
Trade-offs
- The composition root has to name every module assembly. Discovery scans exactly the list it is
handed (
ModuleLoader.cs:59-65), so adding a module to a host is a third edit beside the project reference and theModulesconfiguration entry: the assembly list in that host'sProgram.cs(MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:131-133). An assembly left out of the list contributes nothing, and the module surfaces as "disabled" (absence equals disabled,ModulesSettings.cs:18-19), not as an error. The trade is deliberate: an ambient scan would make the same omission depend on whether some code path happened to load the assembly first (ModuleLoader.cs:49-54). - Stubs degrade silently by design. A stub answers success-shaped defaults: an always-open live
window (
DisabledEventLiveValidationService.cs:27,36-43), a passing bookmark validation (DisabledSessionBookmarkValidationService.cs:33-34), zero counts (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/UserSessionBookmarks/DisabledBookmarkCountService.cs:7-23), an empty attendee list (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/DisabledAttendeeQueryService.cs:7-12), empty exports (MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/Exports/DisabledUserEngagementExportService.cs:7-12,MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.Shared/UserNotifications/DisabledUserNotificationExportService.cs:7-12,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Exports/DisabledUserConferenceExportService.cs:7-12,MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.Shared/Exports/DisabledUserSalesExportService.cs:10-11,MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/Exports/DisabledUserCatalogExportService.cs:7-12). The export stubs are the sharpest case: because the stub answers successfully, the consuming section reports itselfCompletewith nothing in it rather than unavailable (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/ExportUserData/SalesUserDataExportSection.cs:52-56), so a data-subject export assembled while a module is disabled reads as a complete document. A wrongly disabled module therefore produces plausible wrong answers rather than an error, and the only startup signal is a Degradedmodule-{Name}health check plus a log line (DependencyInjection.cs:229-236,ModuleLoader.cs:334-335,343-344). - The dependency graph is a hand-written declaration, not a derived fact. Five modules consume a
peer over gRPC without listing it in
Dependencies. ADC Conference consumes Engagement'sIBookmarkCountService(ConferenceModule.cs:16-32versusConference.Service/Program.cs:431). ADC Engagement declares only["Conference"]yet its host wires Notification's live-channel client (EngagementModule.cs:20versusMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:303). ADC Notification declares only["Identity"], and lists only Identity underRemoteDependencies, yet its host wires Conference's event-live validation client (NotificationModule.cs:21versusMMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:242,MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/appsettings.json:31-34). ADCIdentityModuledeclares noDependenciesyet its host wires the Engagement, Notification and Conference export clients (MMCA.ADC.Identity.API/IdentityModule.cs:13-25versusMMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:342-344). Store Identity consumes the Sales and Catalog export services while itsIdentityModuledeclares noDependenciesat all (MMCA.Store.Identity.API/IdentityModule.cs:16-47versusMMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:284,295). Neither the topological sort nor theRequiresDependenciescheck knows about those edges. Nothing derivesDependenciesfrom the interfaces a module actually resolves. What is pinned is the declaration against a written expectation:ModuleConformanceTestsBase<TModule>assertsName,Dependencies,RequiresDependencies(read through theIModuleinterface, so a leaf module is checked against the framework defaults too) and whatRegisterDisabledStubsputs in the container (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Layering/ModuleConformanceTestsBase.cs:21-64). That turns a silent edit of a declaration into a red test, but it is opt-in per module and seven of the eight subclass it today: every Store module (Catalog, Identity and Sales) and every ADC module (Conference, Engagement, Identity and Notification,MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/ConferenceModuleTests.cs:10,MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/EngagementModuleTests.cs:9). Only Helpdesk Tickets has no subclass, so its declaration alone is still unchecked. - The one guard against a forgotten cross-process rewire is unadopted.
ValidateRemoteDependencieswas written for exactly the "typo'd or forgottenAddTypedGrpcClient" failure (ModuleLoader.cs:198-211, the text at:203), and no host calls it, so that failure still shows up as a stub no-op at request time. - Names are strings, and they are not all matched the same way. The loader's own maps key on
Namecase-insensitively: the stub snapshot, the seeder lookup and the sort graph (ModuleLoader.cs:21,105,284-290). The configuration lookup is ordinal and case-sensitive:ModulesSettingsis aDictionary<string, ModuleSettings>with no comparer andIsModuleEnabledis a plainTryGetValue(ModulesSettings.cs:7,18-19), so aModuleskey whose casing differs fromNamereads as absent and therefore disabled. Renaming a module without updating every dependent'sDependencieslist and every host'sModulessection likewise yields a module treated as disabled with, at worst, a warning. - Controller filtering does not read "disabled" the way the loader does. The provider builds its
disabled set from the entries present in the
Modulessection whoseEnabledis false (ModuleControllerFeatureProvider.cs:36-39), while the loader treats a module missing from that section as disabled (ModulesSettings.cs:18-19). A module simply left out of configuration therefore gets noRegistercall and none of its services, yet keeps its controllers mapped: the map-then-500-on-request case this provider exists to prevent (ModuleControllerFeatureProvider.cs:19-25). Every host today enumerates its whole module set and pins each disabled peer to"Enabled": false(MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/appsettings.json:20-24,MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/appsettings.json:21-26; the Helpdesk seed has only the one module to list,MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/appsettings.json:13-15), so the divergence is latent rather than live. The accepted position is that a host enumerates its modules rather than leaning on the absence rule. - Controller filtering is coupled to assembly and namespace naming. A disabled module's
controllers are found by a
.{ModuleName}.substring test on the assembly name or namespace (ModuleControllerFeatureProvider.cs:60-82), so a project that does not follow theMMCA.{Repo}.{Module}.{Layer}convention keeps its endpoints mapped while its services are absent. - Every module is constructed by
Activator.CreateInstance(ModuleLoader.cs:99), so a module type needs a public parameterless constructor and cannot take injected dependencies; anything a module needs at registration time has to arrive through theRegisterparameters.
Revision (2026-10-01)
The decision is unchanged; four statements are corrected against the code and the citations are
re-anchored. A GetTypes() failure is no longer a skip: a ReflectionTypeLoadException keeps the
types that did load and logs at Error, and only any other exception drops the assembly
(MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:81-93,349-353,
MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Modules/ModuleLoaderTests.cs:223-255). Module
names are not matched case-insensitively everywhere: the Modules configuration lookup is an
ordinal TryGetValue (MMCA.Common/Source/Core/MMCA.Common.Application/Settings/ModulesSettings.cs:7,18-19).
An extracted host never discovers its peers, so it registers gRPC clients rather than replacing peer
stubs (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:131-133,244-245). Store's
inventory grew a Catalog export contract: CatalogModule stubs IUserCatalogExportService
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.API/CatalogModule.cs:31-32) and Identity adds
a Catalog export section (MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/IdentityModule.cs:45),
an undeclared edge now named beside the ADC one. DisabledAttendeeQueryService is listed as an
empty attendee list rather than an export, and the ADR-015 isolation base is cited at its new
Bases/Layering/ path.
Revision (2026-10-06)
- No content change: the decision, the module inventory and every behavioral statement still match the code.
- Anchors re-verified against current source: the Store and ADC service hosts' module-assembly lists,
the Sales gRPC clients (
MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:246-247), the Store Identity export clients (MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:265,276), the ADC Conference bookmark-count client (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:416), the seeding call (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:124) andModuleIsolationTestsBase(:8-40, now seven facts). The Sales host assembly list cited in the 2026-10-01 revision is now atMMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:132-134.
Revision (2026-10-07)
Re-verified against current source. The decision, the module inventory and every behavioral
statement are unchanged: AddModuleHealthChecks still publishes one Healthy module-{Name} check per
enabled module and one Degraded check per disabled module, startup database initialization still runs
the module seeders, and ADC Conference still wires Engagement's IBookmarkCountService client without
declaring Engagement in Dependencies. Only line numbers moved.
- Anchors re-verified against current source: the ADC Conference
AddModuleHostcall (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:371-373), the ADC Conference bookmark-count client (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:418), theInitializeDatabaseAsyncsignature and its seeding call (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/DatabaseInitializationExtensions.cs:51-54,137; line 124 is now the pending-migrations check, so the 2026-10-06:124seeding anchor is stale), andAddModuleHealthChecks(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:212-234, the Degraded branch at:224-231).
Revision (2026-10-19)
No decision changed. The Trade-offs count of declaration-checked modules moves from five of eight to
seven of eight: ADC adds ConferenceModuleTests, which pins Conference with no dependencies and
RequiresDependencies false
(MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/ConferenceModuleTests.cs:9, :11,
:15, :17), and EngagementModuleTests, which pins Engagement depending on Conference with
RequiresDependencies true
(MMCA.ADC/Tests/Modules/Engagement/MMCA.ADC.Engagement.API.Tests/EngagementModuleTests.cs:9, :11,
:13, :15). Helpdesk Tickets is the one module still unchecked. The undeclared Conference-to-Engagement
gRPC edge recorded in the 2026-10-07 revision is unchanged, and the new test pins that declaration as it
stands rather than resolving it.
Same date, separately: the Helpdesk host no longer calls DiscoverAndRegister as a bare statement.
It runs inside a Register step of services.AddMmcaApplicationPipeline(...)
(MMCA.Helpdesk/Source/Hosts/MMCA.Helpdesk.Web/Program.cs:121, step :125-133), so discovery sits
between AddApplication() and AddApplicationDecorators() by construction. The scan input is
unchanged (one named assembly, typeof(TicketsModule).Assembly at :132); the Decision's dead
:116-124 anchor is re-pointed.
Revision (2026-10-21)
Re-verified against current source. The decision, the strictness rules and the undeclared
Conference-to-Engagement gRPC edge are unchanged (ConferenceModule still declares no
Dependencies); the Conference module's stub set and the live-validation stub grew, and the host
anchors moved (by one to eight lines; the Store Sales gRPC client block gained a :241 anchor).
ConferenceModuleregisters three stubs, not two:ISessionBookmarkValidationService,IEventLiveValidationServiceand the data-subject export contractIUserConferenceExportServicebacked byDisabledUserConferenceExportService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/ConferenceModule.cs:24-26, class at:16-32;MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Exports/DisabledUserConferenceExportService.cs:7-12).ConferenceModuleTests.AssertDisabledStubsasserts all three (MMCA.ADC/Tests/Modules/Conference/MMCA.ADC.Conference.API.Tests/ConferenceModuleTests.cs:20-46, the export stub at:42-45).DisabledEventLiveValidationServicehas five fail-open members: the fifth,GetOverlappingSessionIdsAsync, answers an empty overlapping-session set (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/DisabledEventLiveValidationService.cs:65-74, class at:23-75).- The Trade-offs list of empty-export stubs now names the two it omitted:
DisabledUserConferenceExportService(above) and Store'sDisabledUserCatalogExportService(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.Shared/Exports/DisabledUserCatalogExportService.cs:7-12). - Anchors re-verified against current source: the
AddModuleHostcalls in Store Identity (MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:120-122), Store Sales (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:133-135), ADC Identity (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:266-268), ADC Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:373-375), ADC Engagement (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:214-216) and ADC Notification (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:196-198); the Store Sales gRPC clients registered after.Register(moduleHost.RegisterModules)(MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:241,247-248); the Store Identity export clients (MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:273,284); the ADC Conference bookmark-count client (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:420); and theConferenceModuleTestsclass (ConferenceModuleTests.cs:10, withExpectedNameat:12,ExpectedDependenciesat:16andExpectedRequiresDependenciesat:18).
Revision (2026-10-26)
Re-verified against current source. The decision, the module inventory, the strictness rules and both
undeclared gRPC edges are unchanged: ConferenceModule and Store's IdentityModule still declare no
Dependencies, each extracted host still names only its own module assembly in a three-line
AddModuleHost call, and the Store Sales gRPC clients are still registered after the loader. Only
line numbers in the service hosts moved (by six to eleven lines).
- Anchors re-verified against current source: the
AddModuleHostcalls in Store Catalog (MMCA.Store/Source/Services/MMCA.Store.Catalog.Service/Program.cs:131-133), Store Identity (MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:126-128), Store Sales (MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:139-141), ADC Identity (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:272-274), ADC Conference (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:379-381), ADC Engagement (MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:220-222) and ADC Notification (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:202-204); the Store Sales gRPC clients registered after.Register(moduleHost.RegisterModules)(MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:252,258-259); the Store Identity export clients (MMCA.Store/Source/Services/MMCA.Store.Identity.Service/Program.cs:284,295); and the ADC Conference bookmark-count client (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:431). The host anchors in the 2026-10-01, 2026-10-06, 2026-10-07 and 2026-10-21 revisions are left as written and are superseded by these.
Revision (2026-10-28)
Re-verified against current source. The decision, the module inventory, the strictness rules, the
health-check behavior and the stub behavior are unchanged: AddModuleHealthChecks still publishes
one Healthy module-{Name} check per enabled module and one Degraded check per disabled module,
SalesModule still declares ["Catalog", "Identity"] with RequiresDependencies = true, and the ADC
Identity host still names only the IdentityModule assembly. The record of undeclared gRPC edges
was incomplete, and the ADR-008 reading was half right.
- The Trade-offs list of undeclared cross-module gRPC edges names three more consumers beside ADC
Conference and Store Identity. ADC Engagement declares only
["Conference"](MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.API/EngagementModule.cs:20) yet its host wiresAddNotificationLiveChannelClient(MMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:303). ADC Notification declares only["Identity"](MMCA.ADC/Source/Modules/Notification/MMCA.ADC.Notification.API/NotificationModule.cs:21) and lists only Identity underRemoteDependencies(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/appsettings.json:31-34) yet its host wiresAddConferenceEventLiveValidationClient(MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:242). ADCIdentityModuledeclares noDependencies(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/IdentityModule.cs:13-25) yet its host wires the Engagement, Notification and Conference export clients (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:342-344). The 2026-10-26 revision's "both undeclared gRPC edges" is left as written and is superseded by this item. The Decision's per-service configuration paragraph no longer presents ADC Conference as the only host wiring an undeclared peer client: it names all four ADC hosts and the ADC IdentityModulesblock (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/appsettings.json:30-33). - ADR-008 treats only
ModuleLoaderas Context (Website/docs-src/adr/008-service-extraction-topology.md:30); theDisabled*stubs appear in its Decision, in the Strangler Fig extraction step (:57-59), and its "one module enabled" sentence is at:51. The Context paragraph and the Related entry now say so. - Anchors re-verified against current source:
AddModuleHealthChecks(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:217-239, the Degraded loop at:229-236);SalesModule(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/SalesModule.cs:24-99,RequiresDependenciesat:37, the stub registration at:46-47); the always-open live windows inDisabledEventLiveValidationService(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/Events/Live/DisabledEventLiveValidationService.cs:27,36-43); and the ADC IdentityAddModuleHostcall (MMCA.ADC/Source/Services/MMCA.ADC.Identity.Service/Program.cs:273-275). The anchors in the 2026-10-07 and 2026-10-26 revisions are left as written and are superseded by these.
Related
ADR-008 (the extraction topology that consumes this model: "Each service is the monolith with one
module enabled" is a statement about ModuleLoader, cited there as pre-existing context, plus the
Disabled* stubs its Strangler Fig step relies on), ADR-006 (the database axis of the same split;
a module's data source is orthogonal to its composition), ADR-007 (the typed gRPC clients that replace a disabled module's stubs at the host
level), ADR-014 (AddApplicationDecorators() must run after every module's handler scan, which is why
module registration is a distinct, ordered startup step), ADR-015 (the module-isolation fitness
functions that keep cross-module traffic on the *.Shared contracts this model registers,
MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Layering/ModuleIsolationTestsBase.cs:8-40).