ADR-020: Domain boundary contracts — three tiers (rest / grpc / internal), structure, naming & layering
Substantially revised 2026-06-20: adds the by-domain folder layout, the three-tier contract model, interface/adapter naming, the service+adapters layering, the error model, and principal handling. URL scheme, versioning, and gateway auth are split into ADR-021.
Context
ADR-018 made API contracts OpenAPI-first; this ADR defines what crosses a domain boundary, in what shape, and how each domain is structured so that a domain can later be extracted into its own service with zero change to its consumers.
A boundary has two audiences with two transport destinies:
- Public / HTTP clients (console, CLI, partners) — over HTTP now and after extraction. Contract = a resource.
- Other backend domains — in-process now, gRPC after extraction. Contract = the domain.
Decision
1. Three contract tiers per domain
| Tier | Audience | Source | Transport now → later |
|---|---|---|---|
| rest | HTTP clients | generated from OpenAPI | HTTP → HTTP |
| grpc | other domains (wire) | generated from proto | (unused now) → gRPC |
| internal | other domains (in-process) | hand-written Kotlin port over proto messages | in-process call → Remote adapter (gRPC client) |
The proto message is the contract that crosses an internal boundary (it becomes the gRPC message
at extraction). The hand-written internal port is the Kotlin interface consumers actually call;
grpc is its wire form, realized at extraction. Events stay producer-owned proto (ADR-019).
2. Folder layout — by domain, everywhere
api-spec/<domain>/
rest/v<n>/*.yaml REST spec (source)
grpc/v<n>/*.proto gRPC/proto spec (source) (common = pseudo-domain: api-spec/common/...)
│ codegen
▼
backend/shared/api-contracts/<domain>/
rest/ GENERATED REST models + {Domain}Api interface (BUILD only → bazel-out)
grpc/ GENERATED proto messages (+ gRPC service stub later) (BUILD only → bazel-out)
internal/ HAND-WRITTEN {Domain}InternalApi.kt (the only committed .kt here)
backend/<domain>/
service/ {Domain}Service (the domain core)
impl/ the adapters (below)
By-domain means extraction is a folder move; targets read as ownership
(//backend/shared/api-contracts/organization:internal). Versioning of these folders/packages is
governed by ADR-021.
3. Interface naming — "default unqualified, special qualified"
{Domain}Api— REST interface, generated (name derives from the operationtags→{Tag}Api). Bare, because REST is the public/default surface.{Domain}InternalApi— the hand-written in-process port, over proto messages +DomainResult. Qualified, because it is the internal surface.- gRPC service stub — generated (later).
Type naming (the three representations of one concept, disambiguated by name + package):
- REST resource (public): clean noun —
Organization(generated from OpenAPI; the public/frontend name stays clean). - Domain aggregate (internal core):
{Concept}Entity—OrganizationEntity(what the{Domain}Servicespeaks; rich types, audit columns). - Proto message (internal contract):
{Concept}Protosuffix on EVERY message —OrganizationProto,CreateOrganizationRequestProto, etc. (resources and requests), so the serialization form is unmistakable in cross-file reads. The suffix lives in the.proto(and thus the gRPC contract) — an accepted cost since gRPC is an internal surface; uniformity across all messages wins readability.
4. Adapter naming — {Domain}{Tier}Adapter (never *Impl)
In ports-and-adapters the implementation is an adapter, and the tier is the technology dimension
(*Impl is rejected as an intent-free smell):
| Adapter | implements | side | when |
|---|---|---|---|
{Domain}RestAdapter | {Domain}Api | owner | now |
{Domain}InternalAdapter | {Domain}InternalApi (in-process) | owner | now |
{Domain}GrpcAdapter | gRPC service stub | owner | at extraction |
{Domain}RemoteAdapter | {Domain}InternalApi (gRPC client) | consumer | at extraction |
Generated REST wiring function is {domain}Routes.
5. Layering — adapters → one core
{Domain}Service is the only core: it speaks domain entities + DomainResult; business inputs
are explicit params; the caller principal comes from CoroutineContext (§8); it never touches Ktor
ApplicationCall / proto / REST types. The adapters are thin and all delegate to it.
Guardrails (review-enforced):
- Adapters are logic-free (structural mapping + principal plumbing). All validation/rules live in the service — otherwise the REST and in-process paths skew (a correctness/security bug).
- Both adapters call the service directly — never REST → InternalApi.
- Entity↔contract mapping lives in the adapters, not on the entity (no
toPublic(); the entity stays contract-agnostic — it must not depend on generated REST/proto types). - A request maps to a domain command/input; the service mints the entity (identity, audit, invariants).
- Only the
internalport gets a second (Remote) adapter; the REST adapter is single-impl.
6. Contract vs envelope, and dual-exposed entities
- The contract = the proto message
T(crosses the wire).DomainResult<T>is the in-process return envelope —Tis the contract; the envelope is a hand-rolled sealed type inbackend/shared/outcome(NOTkotlin.Result, whose error side is an untypedThrowableby design), never on the wire. At extraction theRemoteAdapterre-synthesizesDomainResultfrom (response | gRPC status). - Dual-exposed entities are two independent contracts, not mirrors. An entity exposed both over REST and in-process gets a REST resource schema and a proto domain message, deliberately shaped for their audiences and free to diverge, joined by an explicit, tested edge mapper. A proto/domain type is never serialized to an HTTP client. Internal-only → proto only; public-only → REST only. Police enum/validation parity with a mapping test.
7. Error model — errors are values, adapters are total (no throwing)
- Errors are values everywhere (
DomainResult.Err). Services, ports, and adapters all returnDomainResult<T>and never throw domain errors. - Every adapter method is total — its body is wrapped in
domainCatch { … }, which returns the block'sDomainResult, rethrowsCancellationException(coroutine correctness — never swallow), and converts any otherException(notThrowable— let JVMErrors die) intoErr(INTERNAL), logging the real cause at ERROR (never leaked). So nothing escapes the adapter; there is noorThrow/DomainException/ApiErrorMapperand no global exception handler. - Two layers of mapping: the service maps failures it understands into meaningful values (
Err(CONFLICT),Err(NOT_FOUND)); the adapter'sdomainCatchis the fallback for the unexpected (Err(INTERNAL)). - Domain errors implement
DomainError { category: ErrorCategory; code: String; message: String }.categoryis transport-neutral (seeded fromgoogle.rpc.Code:NOT_FOUND,CONFLICT,INVALID,UNAUTHENTICATED,PERMISSION_DENIED,FAILED_PRECONDITION,INTERNAL, …) — not an HTTP status. - The transport binding renders the
DomainResult— the generated REST wiring doeswhen (result) { Ok → respond(200, value); Err → respond(category.httpStatus, error.toProblemDetails()) }(no try/catch); the future gRPC adapter mapscategory → gRPC Status. (Why hand-rolledDomainResultnotkotlin.Result: the latter's failure is a fixed, untypedThrowable— it can't carry a typedDomainError/categoryor give exhaustivewhen, andrunCatchingswallowsCancellationException.)
8. Principal handling
The authenticated caller flows via CoroutineContext, populated at the HTTP edge from the JWT /
X-Aucert-* claim headers and read in-process by adapters/service. Business/subject identifiers are
explicit params, never ambient. Deferred (later sweep): carrying the principal over gRPC metadata
at extraction, and a system-principal mechanism for jobs/event handlers/cron (which have no HTTP edge,
hence an empty context).
9. Shared modules
backend/shared/outcome—DomainResult(hand-rolled sealedOk|Err),DomainError,ErrorCategory,domainCatch(consolidates today's per-domainDomainResultcopies). Descriptive name on purpose — cute codenames are reserved for domains (e.g.hangar), not shared infra.- No
shared/web. The no-throw model made it vestigial. Its residuals: theDomainError → ProblemDetails/ErrorCategory → httpStatusmapper rides withapi-contracts/common(beside the generatedProblemDetails); the principal-from-headers →CoroutineContextplugin lives inbackend/shared/auth; malformed-body →400is in the generated REST wiring template. api-contracts/<domain>/internaldepends onoutcome+ that domain'sgrpc(proto messages), and nothing from anyimpl. Consumers depend on:internalonly;implis visible to//backend/appalone.
Consequences
In-process callers receive serialization-shaped types (string timestamps/ids, generated enums), not
rich Kotlin types — deliberate, so the boundary is a genuine contract and in-process == remote
(a domain moves behind a RemoteAdapter with zero caller change).
Current state / migration
The first pass (this branch) used an interim shape: in-process contracts generated as OpenAPI
schema-only specs (not proto), the old kind-first folder layout (api-spec/rest/v1/<domain>), hand-written
ports named {Domain}Api returning OpenAPI types, and hand-written routes. The target above
supersedes that. Migration is staged domain-by-domain. Done in interim form: subscription, catalog
(+authoring), onboarding, organization, user, identity, entitlement, metering, payment, hangar.
Deferred: validationgraph (legacy proto/, Hard Rule 4).
Alternatives rejected
- Generate the in-process port from a proto
serviceblock — gRPC-runtime-coupled (grpc-kotlinCoroutineImplBase) or a bespoke protoc plugin, and forces every method into unaryrpc(Request) returns (Response), losing primitives/Duration/defaults/List/nullable returns/DomainResult. - "proto canonical, REST derived" — re-couples the public API to the domain shape; undermines independent evolution.
*Impladapter naming — intent-free; ports-and-adapters calls these adapters.- Hand-written public projection distinct from the wire DTO, or exposing the entity — drift / leaks internal ids/audit.