Skip to main content

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

TierAudienceSourceTransport now → later
restHTTP clientsgenerated from OpenAPIHTTP → HTTP
grpcother domains (wire)generated from proto(unused now) → gRPC
internalother domains (in-process)hand-written Kotlin port over proto messagesin-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 operation tags{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 nounOrganization (generated from OpenAPI; the public/frontend name stays clean).
  • Domain aggregate (internal core): {Concept}EntityOrganizationEntity (what the {Domain}Service speaks; rich types, audit columns).
  • Proto message (internal contract): {Concept}Proto suffix on EVERY messageOrganizationProto, 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):

Adapterimplementssidewhen
{Domain}RestAdapter{Domain}Apiownernow
{Domain}InternalAdapter{Domain}InternalApi (in-process)ownernow
{Domain}GrpcAdaptergRPC service stubownerat extraction
{Domain}RemoteAdapter{Domain}InternalApi (gRPC client)consumerat 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):

  1. 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).
  2. Both adapters call the service directly — never REST → InternalApi.
  3. 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).
  4. A request maps to a domain command/input; the service mints the entity (identity, audit, invariants).
  5. Only the internal port 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 envelopeT is the contract; the envelope is a hand-rolled sealed type in backend/shared/outcome (NOT kotlin.Result, whose error side is an untyped Throwable by design), never on the wire. At extraction the RemoteAdapter re-synthesizes DomainResult from (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 return DomainResult<T> and never throw domain errors.
  • Every adapter method is total — its body is wrapped in domainCatch { … }, which returns the block's DomainResult, rethrows CancellationException (coroutine correctness — never swallow), and converts any other Exception (not Throwable — let JVM Errors die) into Err(INTERNAL), logging the real cause at ERROR (never leaked). So nothing escapes the adapter; there is no orThrow/DomainException/ApiErrorMapper and no global exception handler.
  • Two layers of mapping: the service maps failures it understands into meaningful values (Err(CONFLICT), Err(NOT_FOUND)); the adapter's domainCatch is the fallback for the unexpected (Err(INTERNAL)).
  • Domain errors implement DomainError { category: ErrorCategory; code: String; message: String }. category is transport-neutral (seeded from google.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 does when (result) { Ok → respond(200, value); Err → respond(category.httpStatus, error.toProblemDetails()) } (no try/catch); the future gRPC adapter maps category → gRPC Status. (Why hand-rolled DomainResult not kotlin.Result: the latter's failure is a fixed, untyped Throwable — it can't carry a typed DomainError/category or give exhaustive when, and runCatching swallows CancellationException.)

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/outcomeDomainResult (hand-rolled sealed Ok|Err), DomainError, ErrorCategory, domainCatch (consolidates today's per-domain DomainResult copies). 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: the DomainError → ProblemDetails / ErrorCategory → httpStatus mapper rides with api-contracts/common (beside the generated ProblemDetails); the principal-from-headers → CoroutineContext plugin lives in backend/shared/auth; malformed-body → 400 is in the generated REST wiring template.
  • api-contracts/<domain>/internal depends on outcome + that domain's grpc (proto messages), and nothing from any impl. Consumers depend on :internal only; impl is visible to //backend/app alone.

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 service block — gRPC-runtime-coupled (grpc-kotlin CoroutineImplBase) or a bespoke protoc plugin, and forces every method into unary rpc(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.
  • *Impl adapter 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.

References

  • ADR-018 — OpenAPI-first contracts + codegen
  • ADR-019 — producer-owned events (proto + listener ports)
  • ADR-021 — URL scheme, versioning, gateway auth
  • SPEC-039 — codegen design + layout