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

Architecture Decision Record

ADR-008: Extraction of the Modular Monolith into Per-Module Services + Gateway

Status

Accepted. Amended by ADR-089 (2026-08-18): the Gateway keeps the route-to-service map this record gave it, but stops expressing it as MapForwarder calls in code. YARP ReverseProxy configuration becomes the single route source, the per-destination HTTP version policy moves into cluster configuration, and a route-map test becomes the drift gate. The topology decision itself is unchanged. See also ADR-088 (2026-08-18) for the cross-cutting behavior the Gateway gains at the same time, which is the first added to it since this record. Revised 2026-09-04: the extraction sequence this topology supports is named as the Strangler Fig route (a new service beside the combined host, one route prefix moved at a time at the Gateway, the old host retired last); ADC's own history was a one-step cutover and is recorded as such below. Revised 2026-09-25: the Gateway pipeline description is corrected against the code (both Gateways layer Key Vault configuration, and ADC's Gateway alone carries an authorization middleware pair that evaluates each route's declared anonymous policy, plus a session-asset upload body cap), and every anchor is refreshed against current line numbers. The topology decision is unchanged. Revised 2026-10-01 (extracted hosts discover only their own module and reach peers over gRPC clients, and JWKS federation bypasses the Gateway in production; see Revision below). Revised 2026-10-07: the cross-service tier's cadence is corrected to weeknights only (a scheduled night is skipped when main has not moved since the last successful run), the single arm-deploy step is scoped to main.bicep, and anchors are refreshed after the v1.233.0 release.

Context

ADC began as a modular monolith: one MMCA.ADC.WebAPI host loaded every module (Identity, Conference, Engagement, Notification) in-process via the ModuleLoader, sharing one database and dispatching domain/integration events in-process. The modular structure (per-module Domain/Application/ Infrastructure/API/Shared, with strict module isolation enforced by the architecture tests) was always maintained, but everything deployed and scaled as a single unit.

Two forces pushed past that:

  • Uneven scale + cost. Traffic is lopsided: the public Conference read paths spike around the event while Identity/Engagement/Notification stay flat (the 2026 conference peaked at ~67 concurrent, almost all on the Conference surface). Scaling one monolith means scaling everything.
  • Fault & deploy isolation. A bad deploy or a runaway path in one module took the others down with it, and every change redeployed the whole surface.

The modular boundaries were already clean enough to split along; the question was how to extract without rewriting business logic or losing the ability to reason about (and revert) the system.

Decision

Extract one service host per module: MMCA.ADC.{Identity,Conference,Engagement,Notification}.Service and front them with a single YARP reverse-proxy Gateway (MMCA.ADC.Gateway, pinned to https://localhost:6001). Delete the combined MMCA.ADC.WebAPI host.

  • Each service is the monolith with one module enabled. The hosts still run ModuleLoader, just with Modules:{Module}:Enabled=true for their own module, and each names only its own module assembly to AddModuleHost (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:371-373), so peer modules are never discovered there; the cross-module interfaces a service consumes are satisfied by gRPC clients the host registers (:418). The Domain/Application/Shared code is identical whether it runs in-process or extracted.
  • Extraction follows the Strangler Fig route; a rewrite is never the plan. Nothing in the topology needs a big-bang switch. A module is extracted by (1) starting its single-module service host beside the combined host, which keeps running with that module turned off (Modules:{Module}:Enabled=false) and its peers satisfied by the Disabled* stubs the ModuleLoader registers for disabled modules (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:12-14, :119); (2) moving that module's route prefix at the Gateway from the combined host's cluster to the new service's, one YARP ReverseProxy configuration change under ADR-089 and no client change, because the Gateway is the only entry point; and (3) retiring the combined host once no route points at it. Old path and new path coexist until traffic has moved, and step (2) reverses by flipping the same route back. ADC took the shortest form of this route, extracting all four modules in one step and deleting MMCA.ADC.WebAPI at the end; a consumer with a live monolith should move one module at a time.
  • The Gateway is the only client entry point. It owns the route-to-service map (/Auth, /Events, /Bookmarks, /hubs, /.well-known, and so on); clients (Blazor/MAUI) never address a service directly. It has no DbContext or controllers. Its pipeline is edge rate limiting (MMCA.ADC/Source/Hosts/MMCA.ADC.Gateway/Program.cs:76, :180), one per-downstream readiness check per service (:90), forwarded headers (:160), correlation (:165), security-headers middleware (ADR-023, :120, :170), CORS (:125, :173), static files (:185), /privacy, /terms and /delete-account minimal-API endpoints (:192, :199, :203), a request-body cap raised only for the session-asset upload path (:215-222), authorization middleware that evaluates the anonymous policy every route declares (AddAuthorization at :114, UseAuthorization at :231; no authentication scheme is registered, so the Gateway authenticates no one, ADR-088), and the proxy itself: the route table is loaded from the ReverseProxy configuration section and mapped by MapReverseProxy (:148-149, :234), not expressed as MapForwarder calls in code (ADR-089). Both Gateways layer Azure Key Vault configuration before anything binds settings (ADC :67, ahead of AddServiceDefaults at :69, the order every ADC service and UI host now follows too, for example MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:114 before :116; Store MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:63). Store's Gateway is the same shape without the static files, the /privacy, /terms and /delete-account endpoints, the upload cap and the authorization pair: its routes declare anonymous too, but it registers no authorization middleware, by design (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/appsettings.json:45-49).
  • Cross-service communication uses edge transports: synchronous calls over gRPC contracts (ADR-007); asynchronous flows over the outbox to MassTransit broker (ADR-003, ADR-006). Token validation is federated via JWKS (ADR-004): locally the AppHost points each service at Identity through the Gateway (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:368-370), while in production each service sets its JWT authority to Identity's internal URL directly (MMCA.ADC/infra/main.bicep:2003, :2139, :2293). Each service owns its own database (ADR-006).
  • Transport stays at the edge, enforced. MicroserviceExtractionTests in the architecture suite forbid gRPC / MassTransit / Protobuf dependencies in any Domain, Application, or Shared assembly, so the core stays host-agnostic and the split stays reversible.

Rationale

  • No business-logic rewrite. Because a service is just the monolith with one module enabled, extraction was a hosting/wiring change, not a domain change, and the module-isolation tests already guaranteed modules didn't reach into each other's internals.
  • One entry point. A single Gateway keeps client config trivial (one pinned URL the MAUI app bakes in), centralizes CORS and auth-forwarding, and lets the internal services run cleartext h2c without exposing that to clients.
  • Per-module split (not coarser or finer). The module already was the consistency and ownership boundary; one service per module maps deploy/scale units onto boundaries the team already reasons about.
  • Reversible by construction. Transport at the edge + the ModuleLoader mean a service can be re-collapsed into a combined host (or peers co-hosted) by a host-level change, not a domain change: a host names the module assemblies it boots in code (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:371-373), and configuration then enables or disables each of them. Useful insurance for a small team adopting microservices.

Trade-offs

  • Operational complexity. Four deployables plus a Gateway, service discovery, a broker, and per-service databases, versus one process. Mitigated locally by Aspire orchestration and in prod by Bicep / Azure Container Apps.
  • Distributed-systems semantics. Cross-service consistency is eventual (outbox + integration events); there are no cross-service transactions or cross-database FKs (ADR-006). Bidirectional gRPC pairs (Conference and Engagement) need deliberate startup handling (ADR-007).
  • Transport constraints leak into hosting. The REST services run Http2-only on cleartext for h2c gRPC, while Notification runs Http1AndHttp2 for its SignalR WebSocket upgrade: a per-host Kestrel nuance that didn't exist in the monolith.
  • Duplicated host wiring. Each service repeats the same pipeline setup; shared concerns live in the framework packages (MMCA.Common.API, plus AddServiceDefaults from MMCA.Common.Aspire at MMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:30) to limit the drift. Neither consumer carries a ServiceDefaults project of its own.

Applicability

This ADR is framed around ADC (the first repo extracted), but the same topology is now the framework's standard extraction shape, not an ADC-only choice. MMCA.Store followed it: one service host per module (MMCA.Store.{Catalog,Identity,Sales}.Service) behind a single MMCA.Store.Gateway (also pinned to https://localhost:6001), with its combined MMCA.Store.WebAPI host likewise deleted. Store's cross-service topology differs in transport detail (see ADR-012: both apps run Profile A on their gRPC-serving edges, while ADC's Notification and Store's Sales run the mixed-endpoint profile, an Http1AndHttp2 default endpoint beside an Http2-only named gRPC endpoint), but the extraction shape is identical.

Why ADC extracted, said plainly. The four hosts (MMCA.ADC/Source/Services/MMCA.ADC.{Identity,Conference,Engagement,Notification}.Service) exist first of all to demonstrate and continuously exercise the extraction path end to end, because "build the monolith now, extract a service later" is the framework's core promise and a promise nothing runs is a claim. They are not the output of a scale, team or deploy-cadence trigger. The conference peaked at roughly 67 concurrent users (Context above), one team owns every module, and all six deployables still ship in a single pipeline run from one template (MMCA.ADC/infra/main.bicep:1675, :1922, :2075, :2210, :2400, :2563, deployed together by a single azure/arm-deploy step for main.bicep at MMCA.ADC/.github/workflows/deploy.yml:1433-1439), so the independent-deploy benefit this record lists is available rather than taken. What ADC does buy with the split is proof under load that the path works: transport stays out of Domain, Application and Shared under a build gate (MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Layering/MicroserviceExtractionTests.cs:3), and the genuine cross-process flows (outbox to broker to consumer, and a real gRPC read) run on weeknights against real containers (MMCA.ADC/.github/workflows/cross-service-tests.yml:6-10, schedule at :26-32; a scheduled night is skipped only when the branch head equals the head of the last successful run, :65-77). A consumer adopting this topology should extract on an observable constraint (a module that must scale or fail apart from the rest, or an owner who must deploy on their own clock) and stay a modular monolith until then; ADC deliberately runs ahead of its own constraints so that consumers do not have to discover the path for themselves.

Revision (2026-10-01)

Three statements are corrected against the code; the topology itself is unchanged. The extracted service hosts do not rely on Disabled* stubs for their peers: each passes only its own module assembly to AddModuleHost (MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:370-372, and likewise in the Engagement, Identity and Notification hosts), so peer modules are never discovered and the cross-module interfaces are wired by host-registered gRPC clients (:417). The ModuleLoader stub path (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:119) serves a combined host that discovers a disabled module. Because the module list is named in host code, re-collapsing services is a host-level change rather than configuration alone. JWKS federation runs through the Gateway only under the AppHost (MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:373-375); production points each service at Identity directly (MMCA.ADC/infra/main.bicep:1930). The remaining anchors (Store Gateway appsettings, ModuleLoader, Extensions.cs, the six main.bicep container apps and the deploy.yml arm-deploy step) are refreshed to current line numbers.

Revision (2026-10-06)

  • No content changed: the topology and every decision above stand.
  • The 2026-10-01 note that the remaining anchors were refreshed no longer holds; current locations: the Conference host names its single module assembly to AddModuleHost at MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:369-371 and registers its gRPC client at :416; the AppHost JWKS wiring through the Gateway is at MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:365-367; production points the JWT authority at Identity at MMCA.ADC/infra/main.bicep:1969, :2103 and :2255; the six container apps sit at :1651, :1890, :2041, :2174, :2353 and :2516, deployed by the single main.bicep arm-deploy step at MMCA.ADC/.github/workflows/deploy.yml:1543-1547.
  • Anchors in the live sections were re-verified against current source.

Revision (2026-10-07)

Re-verified against current source. The topology and every decision above stand: each extracted host still names a single module assembly to AddModuleHost, peers are still reached over host-registered gRPC clients, JWKS federation still runs through the Gateway only under the AppHost, and all six container apps still deploy from one main.bicep template. Two live statements (the cross-service cadence and the arm-deploy step) and the anchors moved.

  1. The cross-service tier does not run nightly: its schedule is weeknights at 06:00 UTC (MMCA.ADC/.github/workflows/cross-service-tests.yml:32, cron 0 6 * * 1-5), and the should-run guard (job at :51) skips a scheduled night only when the branch head SHA equals the head SHA of the last successful run (:65-77), so a night after a failed run still runs. The Applicability section now says "on weeknights".
  2. The single azure/arm-deploy step is single for main.bicep, which holds all six apps (MMCA.ADC/.github/workflows/deploy.yml:1433-1439); a separate arm-deploy step deploys infra/foundation.bicep (:972). The Applicability sentence now names the main.bicep step.
  3. Anchors re-verified against current source: Conference host AddModuleHost call at MMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:371-373 (single-assembly array at :372; likewise Engagement :214, Identity :266, Notification :196), gRPC client registration at :418; AppHost JWKS wiring at MMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:368-370; production JWT authority at MMCA.ADC/infra/main.bicep:2003, :2139 and :2293; the six container apps at :1675, :1922, :2075, :2210, :2400 and :2563; cross-service header at MMCA.ADC/.github/workflows/cross-service-tests.yml:6-10 and trigger block at :26-32.

ADR-003 (outbox dual dispatch), ADR-004 (cross-service token validation via JWKS), ADR-006 (database per service), and ADR-007 (gRPC cross-service calls) are the facet decisions that sit under this extraction.