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.
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; disabled peers are satisfied byDisabled*stubs. 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,:101); (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→service map (
/Auth,/Events,/Bookmarks,/hubs,/.well-known, …); 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:64,:144), one per-downstream readiness check per service (:78), forwarded headers (:124), correlation (:129), security-headers middleware (ADR-023,:84,:134), CORS (:89,:137), static files (:149), a/privacyminimal-API endpoint (:154), and the proxy itself: the route table is loaded from theReverseProxyconfiguration section and mapped byMapReverseProxy(:112-115,:158), not expressed asMapForwardercalls in code (ADR-089). Store's Gateway is the same shape without the static files and the/privacyendpoint, and it additionally layers Azure Key Vault configuration (MMCA.Store/Source/Hosts/MMCA.Store.Gateway/Program.cs:63), which the ADC Gateway does not. - Cross-service communication uses edge transports: synchronous calls over gRPC contracts (ADR-007); asynchronous flows over the outbox → MassTransit broker (ADR-003, ADR-006). Token validation is federated via JWKS through the Gateway (ADR-004). 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 changing configuration, not code: 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 ↔ 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:46) 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:1016, :1223, :1357, :1484, :1653, :1774, deployed together by a
single azure/arm-deploy step at MMCA.ADC/.github/workflows/deploy.yml:1298-1304), 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 nightly against
real containers (MMCA.ADC/.github/workflows/cross-service-tests.yml:6-10, cadence at :25-31). 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.
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.