Architecture Decision Record
ADR-067: Shared Blazor Application Shell and IUIModule Composition
Status
Accepted (2026-08-07).
Context
ADR-059 decided how a module plugs into the server: an IModule implementation is discovered by
reflection, registered in topological order, and a host composes an application out of those modules
without knowing any of them by name. The presentation layer needed the same property, and for a while
did not have it: every Blazor head owned its own App/Routes/layout/nav markup, so adding a module
meant editing the host (a new nav link, a new assembly in the router, a new drawer in the layout), and
two apps built on the same framework drifted apart in shell behavior even where they agreed.
The framework already ships the whole shell as a package (MMCA.Common.UI): the router, the main
layout, the nav menu, and the pages every app has anyway (sign in, register, home, 404, 403, plus the
notification surfaces). What was missing was a contract letting a module contribute into that
shell instead of a host wiring it in by hand.
This is a UI-layer concern that neither existing ADR covers. ADR-059 stops at the server-side module contract; ADR-056 decides which render mode the web heads run in, not who supplies the components being rendered.
Decision
Ship the application shell in the framework package and let each module plug into it by implementing
IUIModule, resolved from DI as IEnumerable<IUIModule>.
- The contract is four members, two of them defaulted.
NavItems,Assembly,AppBarComponentTypesandLayoutComponentTypes(MMCA.Common/Source/Presentation/MMCA.Common.UI/Common/Interfaces/IUIModule.cs:13,16,19,22); the last two default to[], so a module that only contributes pages and navigation is two properties. ANavItemis a record of title, href, icon, optionalRequiredRole/RequiredClaim, aNavSection, an optional collapsibleGroup, and an optionalTitleResourcethat turns the title and group into resource keys (MMCA.Common.UI/Common/NavItem.cs:17, ADR-027). - The router discovers module pages at runtime from the registrations.
Routes.razorinjectsIEnumerable<IUIModule>(MMCA.Common.UI/Routes.razor:4) and handsUIModules.Select(m => m.Assembly)to theRouter'sAdditionalAssemblies, withAppAssemblypinned to the shell's own assembly andNotFoundPageto the shipped 404 page (:7-9). Nothing in the shell names a module. - The shell ships the routable pages every app needs. Home
/(MMCA.Common.UI/Pages/Home.razor:1), Login/loginand Register/register(Pages/Auth/Login.razor:1,Pages/Auth/Register.razor:1), the OAuth return page/auth/oauth-complete(Pages/Auth/OAuthComplete.razor:1),/not-found(Pages/NotFound.razor:1),/forbidden(Pages/Forbidden.razor:1), and the notification surfaces/notifications,/notifications/inbox,/notifications/send(Pages/Notifications/NotificationList.razor:1,NotificationInbox.razor:1,NotificationSend.razor:1). - Unauthenticated and unauthorized both resolve inside the shell.
AuthorizeRouteViewsends an anonymous visitor throughRedirectToLoginand an authenticated-but-unauthorized one to the dedicatedForbiddenpage rather than a bare alert (Routes.razor:11-29). - The nav menu is assembled from the registrations, trimmed per user.
NavMenuinjects the same enumeration (MMCA.Common.UI/Layout/NavMenu.razor:8), flattens every module'sNavItems, drops items whoseRequiredRoleorRequiredClaimthe current principal does not carry, and splits the remainder into the General, My Account and Administration sections (:185-195). - Two component extension points render module-supplied types.
MainLayoutreadsAppBarComponentTypesandLayoutComponentTypesoff the registrations (MMCA.Common.UI/Layout/MainLayout.razor:98-99) and renders them throughDynamicComponentin the top app bar (:41-44, mirrored on the mobile top row atNavMenu.razor:32-35) and at the root of the layout (:80-83), so a module can add an icon with a badge or a drawer without touching the layout. - Registration is one call.
AddUIModule<TModule>()runs the Scrutor scan for the module's entity services and then registers the descriptor as a singletonIUIModule(MMCA.Common.UI/DependencyInjection.cs:152-162); modules with extra services register the descriptor directly withAddSingleton<IUIModule, TModule>()after their own registrations (MMCA.Common.UI/Notifications/DependencyInjection.cs:39,MMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.UI/DependencyInjection.cs:69). - Blazor Web heads feed the same enumeration to the endpoint side.
MapRazorComponents<App>()takes the module assemblies fromGetServices<IUIModule>()in addition to the shell assemblies (MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:186-200, which also de-duplicates, andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:201-212), so the router's view and the endpoint's view of the routable assemblies come from one source.
Adoption today is every module UI in both apps plus the framework's own and its test host: ADC
Conference, Identity and Engagement
(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.UI/ConferenceUIModule.cs:14,
Identity/MMCA.ADC.Identity.UI/IdentityUIModule.cs:13,
Engagement/MMCA.ADC.Engagement.UI/EngagementUIModule.cs:17); Store Catalog, Sales and Identity
(MMCA.Store/Source/Modules/Catalog/MMCA.Store.Catalog.UI/CatalogUIModule.cs:13,
Sales/MMCA.Store.Sales.UI/SalesUIModule.cs:16, Identity/MMCA.Store.Identity.UI/IdentityUIModule.cs:13);
the framework's own notification module
(MMCA.Common/Source/Presentation/MMCA.Common.UI/Notifications/NotificationUIModule.cs:14); and the
backend-less component gallery, whose stub descriptor is the only reason its /components page is
routable (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Gallery/Stubs/GalleryUIModule.cs:13,
registered at GalleryHost.cs:85). Two adopters are host-only: ADC's DeviceUIModule adds the
MAUI-only device settings page plus the deep-link listener
(MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI/DeviceUIModule.cs:19, registered at MauiProgram.cs:121), and
Store's MauiUIModule contributes no nav and no pages at all, existing purely to hang the native
theme sync on the layout extension point (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiUIModule.cs:14,
registered at MauiProgram.cs:72). MMCA.Helpdesk deliberately does not adopt this: the seed's
Blazor head owns its own Routes.razor and MainLayout and never calls AddUIShared, because it has
no ApiSettings-backed client pipeline (MMCA.Helpdesk/Source/Hosts/UI/MMCA.Helpdesk.UI.Web/Program.cs:25-28).
Rationale
- One composition model across both tiers. A module already declares its server-side surface
through
IModule(ADR-059); declaring its UI surface throughIUIModulemeans "add a module" is one registration on each side, not a host edit per contribution point. - The shell is the reusable part. Router, layout, nav, sign-in, 404 and 403 are the same in every
app built on this framework; shipping them in the package is what makes a new head a configuration
exercise rather than a copy of another app's
Componentsfolder. - Defaulted members keep the common case small. Most modules contribute pages and nav only, so
AppBarComponentTypesandLayoutComponentTypesdefault to empty rather than forcing every descriptor to spell out two empty lists. - Runtime discovery beats a compile-time list. Because the router reads the registrations, the same shell serves a web head, a WASM client and a MAUI hybrid head with different module sets, and a head-specific module (device settings, native theme sync) is just another registration that other heads never make.
- Nav trimming belongs in one place. Role and claim gating computed once in
NavMenugives every module the same behavior, instead of each module re-implementingAuthorizeViewaround its links.
Trade-offs
Assemblyis required even when it carries no route. A host-only module that contributes only a layout component still has to return an assembly, which then joinsAdditionalAssembliesand adds nothing;MauiUIModuledocuments exactly that (MMCA.Store/Source/Hosts/UI/MMCA.Store.UI/MauiUIModule.cs:23-27).- The contract carries no route-uniqueness or ordering guarantee. Nothing checks that two modules
declare different
@pageroutes, and nav items render in DI registration order within their section, so the menu's top-level ordering is a function of the host's registration sequence rather than anything declared. - Descriptors are singletons with eagerly built
NavItems. Every adopter initializes the list in a property initializer, so nav content cannot depend on scoped state; per-user variation is limited to theRequiredRole/RequiredClaimfiltering the shell applies at render time. - Hiding a nav item is not authorization. The trimming in
NavMenuis presentation only; route protection still comes fromAuthorizeRouteViewand the pages' own attributes (Routes.razor:11-29). - Blazor Web heads wire the assemblies twice. The router's
AdditionalAssembliesand the endpoint'sAddAdditionalAssembliesare separate calls, so both hosts repeat the enumeration inProgram.cs(MMCA.ADC.UI.Web/Program.cs:186-200,MMCA.Store.UI.Web/Program.cs:201-212); they derive it from the sameIUIModuleregistrations, but the duplication is real. - The reference seed does not demonstrate the pattern. Helpdesk's hand-rolled shell means an adopter following it gets the framework's components but not this composition model.
Related
ADR-059 (the server-side IModule contract this mirrors in the presentation layer), ADR-056 (the
render-mode strategy for the web heads, which decides how these components render but not who supplies
them), ADR-027 (nav titles as resource keys via TitleResource), ADR-042 (the device-capability work
whose MAUI-only heads register host-only UI modules).