Skip to main content

ADR-020: Contract-first integration — packages/contracts is the law (buf + schema registry + CI gates)

Context

ADR-018 makes contracts the permanent architecture and the mechanism that lets a service be rewritten in another language (Payroll TS→Go, ledger Go) with zero impact on callers. That promise only holds if contracts are governed: a stale or silently-broken proto/event schema turns "swap the implementation" into "break every consumer." Today protos exist for ledger/gateway but the boundary is enforced only there, there is no breaking-change gate, and events are largely stringly-typed (payload as any).

Decision

packages/contracts is the single, highest-governed integration surface. All cross-context contracts are defined there first, then implemented.

  • Sync: gRPC protobuf. Workflow is proto PR → buf generate (Go + TS stubs committed) → implement. A buf lint + breaking-change check runs in CI against packages/contracts/grpc.
  • Async: every event has a registered protobuf schema in a schema registry with BACKWARD compatibility enforced (ADR-022). No event ships without a registered schema.
  • Consumer-driven contract tests: each consumer ships a test against the contract; CI fails a producer that breaks a consumer. This is the safety net that makes language swaps safe.
  • Contracts carry the cross-cutting metadata every call needs: tenant id, actor id, idempotency key.

Alternatives considered

  • Hand-written DTOs per side / stringly-typed events — rejected: drift is undetectable until runtime; incompatible with the polyglot goal.
  • OpenAPI/REST for internal calls — rejected: untyped across languages, no breaking-change tooling; REST stays at the edge only (ADR-023).

Consequences

  • Positive: a service rewrite is invisible to callers; breaking changes are caught in CI, not production; one source of truth for every boundary; new contributors read one place to know what a service promises.
  • Negative / accepted: a step of ceremony before implementing (define/regenerate the contract); generated code is committed and must be regenerated on change.
  • Follow-ups: add the buf CI gate; define a PayrollEnginePort proto now (even though Payroll stays NestJS) so the future Go swap is a no-op for consumers; back-fill protobuf schemas for the live events.

Revisit when

  • A third language enters (only via ADR-010 amendment) → the contracts repo must generate its stubs too.