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 withModules:{Module}:Enabled=truefor their own module, and each names only its own module assembly toAddModuleHost(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 theDisabled*stubs theModuleLoaderregisters 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 YARPReverseProxyconfiguration 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 deletingMMCA.ADC.WebAPIat 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,/termsand/delete-accountminimal-API endpoints (:192,:199,:203), a request-body cap raised only for the session-asset upload path (:215-222), authorization middleware that evaluates theanonymouspolicy every route declares (AddAuthorizationat:114,UseAuthorizationat:231; no authentication scheme is registered, so the Gateway authenticates no one, ADR-088), and the proxy itself: the route table is loaded from theReverseProxyconfiguration section and mapped byMapReverseProxy(:148-149,:234), not expressed asMapForwardercalls in code (ADR-089). Both Gateways layer Azure Key Vault configuration before anything binds settings (ADC:67, ahead ofAddServiceDefaultsat:69, the order every ADC service and UI host now follows too, for exampleMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:114before:116; StoreMMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:63). Store's Gateway is the same shape without the static files, the/privacy,/termsand/delete-accountendpoints, the upload cap and the authorization pair: its routes declareanonymoustoo, 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.
MicroserviceExtractionTestsin 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
ModuleLoadermean 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 runsHttp1AndHttp2for 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, plusAddServiceDefaultsfromMMCA.Common.AspireatMMCA.Common/Source/Hosting/MMCA.Common.Aspire/Extensions.cs:30) to limit the drift. Neither consumer carries aServiceDefaultsproject 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
AddModuleHostatMMCA.ADC/Source/Services/MMCA.ADC.Conference.Service/Program.cs:369-371and registers its gRPC client at:416; the AppHost JWKS wiring through the Gateway is atMMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:365-367; production points the JWT authority at Identity atMMCA.ADC/infra/main.bicep:1969,:2103and:2255; the six container apps sit at:1651,:1890,:2041,:2174,:2353and:2516, deployed by the singlemain.biceparm-deploy step atMMCA.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.
- 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, cron0 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". - The single
azure/arm-deploystep is single formain.bicep, which holds all six apps (MMCA.ADC/.github/workflows/deploy.yml:1433-1439); a separate arm-deploy step deploysinfra/foundation.bicep(:972). The Applicability sentence now names themain.bicepstep. - Anchors re-verified against current source: Conference host
AddModuleHostcall atMMCA.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 atMMCA.ADC/Source/Hosting/MMCA.ADC.AppHost/Program.cs:368-370; production JWT authority atMMCA.ADC/infra/main.bicep:2003,:2139and:2293; the six container apps at:1675,:1922,:2075,:2210,:2400and:2563; cross-service header atMMCA.ADC/.github/workflows/cross-service-tests.yml:6-10and trigger block at:26-32.
Related
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.