ADR-020: Contract-first integration — packages/contracts is the law (buf + schema registry + CI gates)
- Status: Accepted
- Date: 2026-06-18
- Deciders: Principal Architect, Engineering Lead
- Relates to: ADR-010, ADR-011, ADR-018, ADR-022
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. Abuflint + breaking-change check runs in CI againstpackages/contracts/grpc. - Async: every event has a registered protobuf schema in a schema registry with
BACKWARDcompatibility 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
bufCI gate; define aPayrollEnginePortproto 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.