Architecture Decision Record
ADR-007: Synchronous Cross-Service Calls via gRPC Contracts
Status
Accepted. Revised 2026-08-23 (the [ServiceContract] marker now has a dedicated fitness rule behind
it, ServiceContractPurityTestsBase, subclassed in all four repos; it is a ratchet that no marked
type triggers yet). Revised 2026-09-04: the *.Contracts gRPC adapter is named for what it is, the
module's Anti-Corruption Layer; no code changed. Revised 2026-10-06: the [ServiceContract] ratchet
is now triggered (ADC marks six interfaces and Store four), a second fitness rule keeps their
implementations non-public, and the h2c, JWKS and disabled-stub bullets are narrowed to current wiring.
Revised 2026-10-07: anchors refreshed after the v1.233.0 release.
Context
Once modules became separate service processes, the in-process interface calls between them (e.g.
Conference → Engagement's IBookmarkCountService, Engagement → Conference's
ISessionBookmarkValidationService) had to cross a process boundary. Asynchronous integration
events (outbox → broker, ADR-003) cover fire-and-forget flows, but some calls need a synchronous
answer. We needed a transport for those that preserved the existing application interfaces and the
Result<T> error model, without coupling application/domain code to a transport.
Decision
Use gRPC, exposed through MMCA.Common.Grpc, with a contract-package convention:
*.Contractsprojects hold the.protodefinitions plus a gRPC adapter that implements the same in-process service interface the modules already used. Any project ending in.Contractsauto-compilesProtos/**/*.protowith both server and client stubs (Directory.Build.props). That adapter is the module's Anti-Corruption Layer: it is the only place the peer's wire model (generated protobuf messages and client stubs) meets the module's own interface andResult<T>types, so a peer's contract never leaks into Domain or Application. The convention is documented on the typed-client registration itself ("register a hand-written adapter that implements the consuming module's own C# interface contract ... and delegates to this typed gRPC client. That adapter IS the consuming module's Anti-Corruption Layer",MMCA.Common/Source/Presentation/MMCA.Common.Grpc/DependencyInjection.cs:69-76, with the package's own class remarks naming both this pattern and the Strangler Fig route at:15-24) and enforced by the transport fitness rule below, which forbids gRPC and protobuf types outside the adapter's layer.- Typed clients via
AddTypedGrpcClient<T>(serviceName)resolvehttp://<service>through Aspire service discovery, wrapped in the standard Polly resilience pipeline and aJwtForwardingClientInterceptor(the inbound bearer token is forwarded downstream). Resultfailures over the wire: the server-sideGrpcResultExceptionInterceptormaps a failedResultto anRpcExceptioncarrying the structuredErrorType/code, mirroring the HTTPHandleFailureedge mapping (ADR-013). Adapters whose interface returns aResult(for exampleSessionBookmarkValidationServiceGrpcAdapter) re-hydrate that failure client-side by parsing theerror-{i}-*trailers, so the caller sees the sameResultshape it would from an in-process call. Adapters whose interface returns a plain type (for exampleTask<int>orTask<IReadOnlyList<T>>) surface a remote failure as a thrown exception instead.- HTTP/2 cleartext (h2c): every gRPC target serves HTTP/2 on a cleartext endpoint so clients
negotiate without TLS/ALPN (a deliberate
SocketsHttpHandleroverride). That is the default endpoint of anHttp2-only service, or a dedicatedHttp2-onlygrpcendpoint on the mixed-profile services (see Trade-offs). - Federated auth, not a shared secret: services validate forwarded JWTs against the issuer's
JWKS (ADR-004) through
AddForwardedJwtBearer, which fetches OIDC discovery and the JWKS from the configured authority and pins RS256 (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.Authentication.cs:52,:109). Under the AppHost the authority is wired byWithJwksDiscovery(identity, gateway), whose gateway argument is optional (MMCA.Common/Source/Hosting/MMCA.Common.Aspire.Hosting/Extensions.cs:309-311); in production the bicep points the authority straight at Identity's internal cleartext URL withRequireHttpsMetadata=false, not through the gateway (MMCA.ADC/infra/main.bicep:2003,:2007;MMCA.Store/infra/main.bicep:1684,:1689). - Disabled-module stubs: when a multi-module host runs with a peer module switched off,
ModuleLoaderregisters that module'sDisabled*stubs so resolution always succeeds (MMCA.Common/Source/Core/MMCA.Common.Application/Modules/ModuleLoader.cs:119). The extracted service hosts name only their own module assembly (for exampleMMCA.ADC/Source/Services/MMCA.ADC.Engagement.Service/Program.cs:213-215), so peers are never discovered, no stub is registered, and the gRPC adapter registration satisfies the interface.
Rationale
- No business-logic rewrite: the gRPC adapter implements the interface modules already depend on; swapping in-process for cross-process is a registration change.
- Transport stays at the edge:
MicroserviceExtractionTestsforbidMassTransit/gRPC types in Application/Domain/Shared, so the choice is reversible and the core stays clean. - Strong contracts: the
.protodefinitions plus the in-process interface the adapter implements are the wire surface. A[ServiceContract(version)]attribute (MMCA.Common.Shared) is provided to mark and version contract types explicitly, and a fitness rule stands behind it:ServiceContractPurityTestsBase.ServiceContracts_ShouldNotDependOn_ServiceInternals(MMCA.Common.Testing.Architecture, rule bodyArchitectureRules.ServiceContractsDoNotDependOnServiceInternals) scans every assembly the repo's architecture map registers and fails any marked type that depends on the producing service's Domain, Application or Infrastructure. All four repos subclass it. A second rule,ContractImplementationTestsBase.ServiceContractImplementations_ShouldNotBe_Public(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Contracts/ContractImplementationTestsBase.cs:33, rule bodyArchitectureRules.ServiceContractImplementationsAreNotPublicatMMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Contracts/ArchitectureRules.Contracts.cs:81), fails any public concrete class implementing a marked interface, with anAllowedPublicImplementationsescape hatch (ContractImplementationTestsBase.cs:30); all four repos subclass it too. Both rules are triggered in ADC and Store, whose architecture maps register the module Shared assemblies (AdcArchitectureMap.cs:31,:39,:47,:53;StoreArchitectureMap.cs:25,:33,:41). ADC marks six interfaces (ISessionBookmarkValidationService.cs:10,IEventLiveValidationService.cs:12,IBookmarkCountService.cs:10,IUserEngagementExportService.cs:13,IAttendeeQueryService.cs:10,IUserNotificationExportService.cs:13) and Store four (IProductVariantService.cs:33,IUserCatalogExportService.cs:19,IUserSalesExportService.cs:20,ICustomerService.cs:25), all with the parameterless form, so none passes a version. MMCA.Common and Helpdesk mark nothing (MMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:12-13), so there the rules stay a ratchet that passes without asserting anything.
Trade-offs
- Bidirectional pairs need care. Conference ↔ Engagement is a mutual gRPC pair; the AppHost
deliberately omits a reciprocal
WaitForto avoid a startup deadlock: transient "peer not ready" errors self-heal via the resilience pipeline. - h2c assumptions. Target services must serve HTTP/2 on cleartext; the Notification service runs
Http1AndHttp2on its default endpoint for its SignalR WebSocket upgrade, unlike theHttp2-only REST services, and carries a second,Http2-onlygrpcendpoint for its gRPC ingress (ADR-012) (MMCA.ADC/Source/Services/MMCA.ADC.Notification.Service/Program.cs:57-72;appsettings.json:13,:17). Store's Sales service runs the same mixed profile: its default endpoints stayHttp1AndHttp2because REST and the Stripe webhook arrive over HTTP/1.1, and a dedicatedHttp2-onlygrpcendpoint servesIUserSalesExportService(MMCA.Store/Source/Services/MMCA.Store.Sales.Service/Program.cs:64-78;appsettings.json:13,:17). - Operational surface. gRPC adds proto tooling, service discovery, and resilience tuning to the deployment.
Revision (2026-10-06)
- The
[ServiceContract]purity rule is no longer an untriggered ratchet: ADC marks six interfaces and Store four (includingICustomerService), so the rule asserts in both; Common and Helpdesk still mark nothing. - Added the second fitness rule the ADR omitted,
ServiceContractImplementationsAreNotPublicviaContractImplementationTestsBase, subclassed in all four repos. - Federated auth: gateway-routed JWKS discovery is the AppHost wiring only; production bicep points the authority directly at Identity's internal cleartext URL.
- Disabled-module stubs apply to multi-module hosts with a module switched off; extracted service hosts never discover peers and rely on the gRPC adapter registration.
- h2c: Store Sales joins ADC Notification on the ADR-012 mixed profile (default
Http1AndHttp2plus anHttp2-onlygrpcendpoint). - Anchors re-verified against current source.
Revision (2026-10-07)
Re-verified against current source. The decision, the JWKS wiring and the [ServiceContract]
adoption are unchanged: ADC production bicep still sets the authority to Identity's internal
cleartext URL with RequireHttpsMetadata=false, and MMCA.Common and Helpdesk still mark no type.
Only anchors moved.
- Anchors re-verified against current source: ADC JWKS authority and
RequireHttpsMetadatare-pointed toMMCA.ADC/infra/main.bicep:2003,:2007(the same pair repeats at:2139,:2143and:2293,:2297); the "MMCA.Common applies to no type" remark re-pointed toMMCA.Common/Source/Core/MMCA.Common.Shared/Abstractions/ServiceContractAttribute.cs:12-13; Store'sMMCA.Store/infra/main.bicep:1684,:1689still hold.