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.
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): the REST services serve HTTP/2 on their cleartext endpoint so clients
negotiate without TLS/ALPN (a deliberate
SocketsHttpHandleroverride). - Federated auth, not a shared secret: services validate forwarded JWTs against the issuer's JWKS (ADR-004), discovered through the gateway.
- Disabled-module stubs: when a service runs with a peer module disabled, it registers a
Disabled*stub for that peer's interface, so resolution always succeeds.
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. The rule is a ratchet, not yet triggered: no contract type carries the attribute today, so the test passes without asserting anything, and the wire surface is currently defined by the.protofiles alone. It bites in a repo the moment its first contract type is marked.
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). - Operational surface. gRPC adds proto tooling, service discovery, and resilience tuning to the deployment.