Build CoreOrdered learning track

Contract Testing and Schema Compatibility

Learn Java Formal Methods, Testing, Benchmarking, and Performance Engineering - Part 015

Contract testing dan schema compatibility untuk Java services: consumer-driven contracts, OpenAPI compatibility, event schema evolution, provider verification, CI gates, compatibility matrix, dan governance untuk API yang tidak merusak consumer.

13 min read2569 words
PrevNext
Lesson 1540 lesson track0922 Build Core
#java#testing#contract-testing#schema-compatibility+5 more

Part 015 — Contract Testing and Schema Compatibility

Tujuan bagian ini: membangun kemampuan mendesain, menguji, dan menjaga kontrak antar-sistem agar perubahan Java service tidak diam-diam merusak consumer, workflow, event processing, reporting, atau integrasi eksternal.

Di part sebelumnya kita sudah membahas fuzzing: bagaimana boundary sistem harus tahan input rusak.

Sekarang kita masuk ke masalah yang lebih sering terjadi di organisasi besar:

Kode provider benar menurut test internal,
tetapi consumer rusak setelah deployment.

Ini bukan bug unit test. Ini bug kontrak.

Contract testing menjawab pertanyaan:

Apakah perubahan service masih memenuhi ekspektasi consumer yang nyata?

Schema compatibility menjawab pertanyaan:

Apakah bentuk data baru masih aman untuk producer dan consumer lama/baru?

Dalam Java enterprise systems, ini muncul pada:

  • REST API berbasis OpenAPI,
  • JAX-RS resource contract,
  • JSON Schema,
  • event payload Kafka,
  • Avro/Protobuf schema,
  • webhook payload,
  • internal message envelope,
  • BPM/workflow command,
  • batch file interface,
  • database-facing integration view,
  • API error response,
  • pagination contract,
  • idempotency key behavior,
  • callback contract,
  • partner integration contract.

Contract testing bukan pengganti integration test. Contract testing adalah guardrail perubahan antar-boundary.


1. Masalah yang Ingin Diselesaikan

Bayangkan service case-service menyediakan endpoint:

GET /cases/{caseId}

Consumer enforcement-dashboard memakai response:

{
  "caseId": "CASE-1001",
  "status": "UNDER_REVIEW",
  "assignedOfficerId": "OFF-7",
  "priority": "HIGH"
}

Lalu provider melakukan refactor:

{
  "id": "CASE-1001",
  "state": "UNDER_REVIEW",
  "owner": {
    "officerId": "OFF-7"
  },
  "priority": "HIGH"
}

Provider test mungkin tetap hijau karena test provider hanya memeriksa:

HTTP 200
non-empty response
valid JSON

Tetapi dashboard rusak karena field caseId dan assignedOfficerId hilang.

Masalahnya bukan JSON invalid. Masalahnya adalah provider melanggar ekspektasi consumer.

Mental model:

Integration tests ask:
Can these components work together now?

Contract tests ask:
Can each component change independently without breaking agreed messages?

2. Apa Itu Contract?

Contract adalah deskripsi eksplisit tentang pesan yang melewati boundary.

Untuk HTTP API, contract mencakup:

method
path
query parameters
headers
request body
response status
response headers
response body shape
allowed values
error format
semantic behavior

Untuk event/message, contract mencakup:

topic/channel
message key
headers
payload schema
required fields
optional fields
field meaning
ordering expectation
idempotency semantics
versioning rule
consumer tolerance

Untuk command/workflow, contract mencakup:

command type
precondition
allowed source state
expected resulting state
domain error
side effect expectation
audit event
idempotency rule

Kontrak yang bagus bukan hanya bentuk data. Kontrak yang bagus juga menjelaskan makna stabil.

Contoh field-level meaning:

caseId:
  stable public identifier of a case.
  must not be database primary key.
  must remain stable across reassignment, escalation, reopen, and archival.

Tanpa semantic description, schema bisa valid tapi behavior tetap salah.


3. API Specification vs Contract Testing

Sering ada kebingungan:

Kami sudah punya OpenAPI, apakah masih perlu contract test?

Jawaban praktis:

OpenAPI menjelaskan provider surface.
Consumer-driven contract menjelaskan ekspektasi consumer tertentu.

Keduanya berbeda.

AspekOpenAPI / Provider SpecConsumer-Driven Contract
Sudut pandangProviderConsumer
FokusAPI surface yang disediakanInteraksi yang benar-benar dipakai
Risiko utama yang ditangkapSpec drift, invalid implementationBreaking consumer expectation
GranularityEndpoint/resourceInteraction/scenario
OwnershipProvider/platform/API governanceConsumer + provider
Cocok untukAPI documentation, codegen, validation, portalIndependent deployment safety

Contoh:

Provider OpenAPI bisa menyatakan field assignedOfficerId optional.

Consumer tertentu mungkin membutuhkan field itu untuk menampilkan SLA breach.

Dari sisi OpenAPI provider, response tanpa field itu valid. Dari sisi consumer, response itu breaking.

Karena itu kontrak harus dibaca dalam dua layer:


4. Jenis Kontrak di Java Systems

Kontrak tidak hanya REST.

4.1 Synchronous HTTP Contract

Contoh:

POST /cases/{caseId}/transitions
Content-Type: application/json
Idempotency-Key: 979cfa1e

Request:

{
  "action": "ESCALATE",
  "reasonCode": "SLA_BREACH",
  "actorId": "OFF-7"
}

Response:

{
  "caseId": "CASE-1001",
  "previousStatus": "UNDER_REVIEW",
  "currentStatus": "ESCALATED",
  "transitionId": "TRN-91"
}

Contract risk:

  • status code berubah,
  • field berubah nama,
  • enum berubah,
  • error body berubah,
  • idempotency behavior berubah,
  • request header baru menjadi required,
  • response latency naik dan consumer timeout,
  • pagination default berubah.

4.2 Asynchronous Event Contract

Contoh Kafka event:

{
  "eventId": "evt-1001",
  "eventType": "CaseEscalated",
  "schemaVersion": 2,
  "occurredAt": "2026-07-02T10:15:30Z",
  "caseId": "CASE-1001",
  "previousStatus": "UNDER_REVIEW",
  "currentStatus": "ESCALATED",
  "reasonCode": "SLA_BREACH"
}

Contract risk:

  • field wajib dihapus,
  • enum value baru tidak ditangani consumer,
  • timestamp format berubah,
  • key partitioning berubah,
  • event ordering expectation rusak,
  • duplicate handling berubah,
  • semantic event digabung/dipecah tanpa migrasi,
  • null vs absent berubah.

4.3 Batch/File Contract

Contoh CSV regulatory export:

case_id,status,assigned_officer_id,deadline_at
CASE-1001,ESCALATED,OFF-7,2026-07-05T23:59:59Z

Contract risk:

  • kolom berubah urutan,
  • delimiter berubah,
  • timezone berubah,
  • header berubah,
  • encoding berubah,
  • escaping berubah,
  • empty string menggantikan null,
  • numeric scale berubah.

4.4 Database Integration Contract

Kadang consumer membaca view atau replication table.

Ini bukan ideal, tetapi sering nyata.

Contract risk:

  • column rename,
  • column type change,
  • changed nullability,
  • changed meaning,
  • changed uniqueness,
  • changed freshness SLA,
  • hidden migration breakage.

Jika database menjadi integration contract, treat schema seperti public API.


5. Compatibility Mental Model

Compatibility harus dilihat dari empat arah:

old consumer -> old provider
old consumer -> new provider
new consumer -> old provider
new consumer -> new provider

Untuk deployment independen, kombinasi paling kritis adalah:

old consumer -> new provider

Provider deploy dulu. Consumer belum tentu ikut deploy.

Maka provider harus menjaga backward compatibility.

Untuk event streaming, lebih kompleks:

old producer -> old consumer
old producer -> new consumer
new producer -> old consumer
new producer -> new consumer

Karena producer dan consumer bisa berjalan paralel untuk waktu lama.

Compatibility matrix:

PerubahanHTTP ResponseEvent SchemaBiasanya Aman?Catatan
Tambah optional fieldYaYaAmanConsumer harus ignore unknown fields
Hapus optional fieldTergantungTergantungHati-hatiBisa dipakai consumer meski optional di spec
Tambah required field pada requestTidakProducer-facing: tidakBreakingConsumer lama tidak mengirim field
Hapus required response fieldTidakTidakBreakingConsumer lama bisa gagal
Rename fieldTidakTidakBreakingTreat as remove + add
Ubah enum dengan value baruTergantungTergantungBerisikoConsumer harus punya unknown handling
Widen numeric rangeTergantungTergantungBerisikoOverflow di consumer
Ubah string date formatTidakTidakBreakingParsing consumer gagal
Tambah endpoint baruYaN/AAmanTidak memengaruhi consumer lama
Ubah status code suksesTergantungN/ABerisikoConsumer sering branch by status
Ubah error bodyTidakN/ABreaking untuk consumer error-awareError contract juga contract

Rule sederhana:

A change is compatible only if every supported consumer can still behave correctly without redeployment.

Bukan hanya compile. Bukan hanya deserialize. Benar secara behavior.


6. OpenAPI Contract Design

OpenAPI adalah standard language-agnostic untuk mendeskripsikan HTTP API agar manusia dan komputer bisa memahami kemampuan service.

Dalam Java service, OpenAPI sering digunakan untuk:

  • API documentation,
  • server stub generation,
  • client generation,
  • request/response validation,
  • test generation,
  • compatibility diff,
  • governance linting,
  • portal/catalog.

Contoh minimal:

openapi: 3.1.0
info:
  title: Case Service API
  version: 1.4.0
paths:
  /cases/{caseId}:
    get:
      operationId: getCase
      parameters:
        - name: caseId
          in: path
          required: true
          schema:
            type: string
      responses:
        '200':
          description: Case found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CaseView'
        '404':
          description: Case not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Problem'
components:
  schemas:
    CaseView:
      type: object
      required:
        - caseId
        - status
        - priority
      properties:
        caseId:
          type: string
          description: Stable public case identifier.
        status:
          type: string
          enum: [DRAFT, UNDER_REVIEW, ESCALATED, CLOSED]
        assignedOfficerId:
          type: string
          nullable: true
        priority:
          type: string
          enum: [LOW, MEDIUM, HIGH, CRITICAL]
    Problem:
      type: object
      required:
        - code
        - message
      properties:
        code:
          type: string
        message:
          type: string
        correlationId:
          type: string

A good OpenAPI contract has:

required fields intentionally chosen
nullable explicitly modeled
additionalProperties policy explicit
error schema stable
pagination schema stable
idempotency header documented
authentication/authorization responses documented
examples realistic
operationId stable
semantic descriptions included

Bad OpenAPI contract:

CaseView:
  type: object
  additionalProperties: true

Ini terlalu longgar. Ia memberi dokumentasi tetapi sedikit sekali protection.

OpenAPI yang terlalu longgar tidak bisa menjadi compatibility gate yang kuat.


7. Consumer-Driven Contract Testing

Consumer-driven contract testing dimulai dari consumer.

Consumer mengatakan:

Untuk menjalankan fitur ini, saya membutuhkan provider merespons request X dengan body berbentuk Y.

Provider kemudian memverifikasi:

Apakah implementation saya masih memenuhi kontrak consumer tersebut?

Flow:

Core advantage:

Provider does not guess what consumers use.
Consumers declare what they need.

Core risk:

Consumer contract can become too narrow, too synthetic, or detached from real behavior.

Karena itu contract tests harus punya governance.


8. What Contract Tests Should and Should Not Test

Contract tests should test:

message shape
required fields
status codes
headers that affect behavior
content type
known error schema
provider state setup
semantics that appear in the message

Contract tests should not test:

complete business workflow
all internal branches
database implementation
performance
UI rendering
provider's private invariants
consumer's internal logic

Contoh buruk:

Consumer contract demands that provider uses PostgreSQL sequence IDs.

Itu bukan contract. Itu implementation leak.

Contoh bagus:

Consumer contract demands that caseId is stable and returned in response body.

Itu public behavior.


9. Provider State

Consumer contracts sering membutuhkan provider berada di state tertentu.

Contoh:

Given case CASE-1001 exists with status UNDER_REVIEW
When GET /cases/CASE-1001
Then response status is 200
And body contains caseId CASE-1001 and status UNDER_REVIEW

Provider verification harus bisa menyiapkan state itu.

Di Java test, state setup biasanya berupa:

void givenCaseUnderReview(String caseId) {
    database.truncateAll();
    officerRepository.insert(new Officer("OFF-7", "Nadia"));
    caseRepository.insert(new CaseRecord(caseId, "UNDER_REVIEW", "OFF-7"));
}

Provider state harus:

  • deterministic,
  • cepat,
  • isolated,
  • idempotent,
  • tidak bergantung order test,
  • tidak memakai production data,
  • tidak membuat hidden dependency antar-contract.

Anti-pattern:

void setup() {
    // assumes previous test already created officer OFF-7
}

Contract verification harus bisa berjalan sendiri.


10. JUnit 5 Provider Verification Shape

Pact JVM menyediakan integrasi JUnit 5 untuk provider verification dengan @TestTemplate: satu template test menghasilkan test per interaction dari pact files.

Bentuk konseptual:

@Provider("case-service")
@PactFolder("pacts")
class CaseServiceContractVerificationTest {

    @BeforeEach
    void before(PactVerificationContext context) {
        context.setTarget(new HttpTestTarget("localhost", 8080));
    }

    @TestTemplate
    @ExtendWith(PactVerificationInvocationContextProvider.class)
    void verify(PactVerificationContext context) {
        context.verifyInteraction();
    }

    @State("case CASE-1001 exists with status UNDER_REVIEW")
    void caseUnderReview() {
        resetDatabase();
        insertOfficer("OFF-7");
        insertCase("CASE-1001", "UNDER_REVIEW", "OFF-7");
    }
}

Jangan hafal annotation. Pahami alurnya:

contract interaction -> provider state setup -> real provider call -> response matching -> verification result

Yang membuat contract verification bernilai adalah real provider call. Bukan mocking controller.


11. Contract Matching: Exact vs Flexible

Consumer contract harus cukup spesifik untuk menangkap breakage, tetapi cukup fleksibel untuk tidak membuat false failure.

Bad exact matching:

{
  "transitionId": "TRN-91",
  "occurredAt": "2026-07-02T10:15:30Z"
}

Jika provider menghasilkan ID/timestamp berbeda, contract gagal padahal behavior benar.

Better matcher:

transitionId matches pattern TRN-[0-9]+
occurredAt is ISO-8601 instant

Prinsip:

Assert stable semantics exactly.
Assert generated data by type/pattern/range.

Exact:

caseId
status
error code
business classification

Flexible:

correlationId
timestamp
generated UUID
server metadata
pagination cursor

12. Error Contract

Banyak tim menjaga success response tetapi mengabaikan error response.

Padahal consumer sering bergantung pada error code.

Contoh stable error contract:

{
  "code": "CASE_NOT_TRANSITIONABLE",
  "message": "Case cannot be escalated from CLOSED state.",
  "correlationId": "corr-123",
  "details": {
    "caseId": "CASE-1001",
    "currentStatus": "CLOSED",
    "requestedAction": "ESCALATE"
  }
}

Error compatibility rule:

Do not remove stable machine-readable error codes.
Do not change status semantics without migration.
Do not move important fields into human text.
Do not make clients parse natural language messages.

Bad API:

{
  "error": "Oops cannot do that because case is closed"
}

Good API:

{
  "code": "INVALID_CASE_TRANSITION",
  "message": "Case cannot be transitioned from CLOSED to ESCALATED.",
  "correlationId": "..."
}

Consumer harus branch pada code, bukan message.


13. Schema Compatibility Rules for JSON

JSON tidak punya compatibility rule sekuat Avro/Protobuf secara default. Maka tim harus menetapkan aturan.

13.1 Safe-ish Changes

Biasanya aman:

add optional response field
add endpoint
add enum field only if consumer is tolerant
relax input validation carefully
add optional request header
add optional query parameter

13.2 Breaking Changes

Biasanya breaking:

remove response field
rename field
change type
change number to string
change string date format
make optional request field required
change nullability
change error code
change status code semantics
change array to object
change object to array
change pagination semantics

13.3 Ambiguous Changes

Butuh consumer evidence:

add enum value
change field max length
change numeric precision
remove optional field
change sorting default
change default page size
add required response field

Kenapa remove optional field ambiguous?

Karena optional di provider spec bukan berarti tidak dipakai consumer.

Consumer bisa punya logic:

if (response.assignedOfficerId() != null) {
    showAssignedOfficer(response.assignedOfficerId());
} else {
    showUnassignedWarning();
}

Jika field dihapus, consumer behavior berubah.

Compatibility tidak hanya schema. Compatibility juga behavior.


14. Unknown Field Tolerance

Consumer JSON parser harus biasanya ignore unknown fields untuk backward compatibility.

Dengan Jackson:

@JsonIgnoreProperties(ignoreUnknown = true)
public record CaseView(
    String caseId,
    String status,
    String assignedOfficerId,
    String priority
) {}

Atau object mapper global:

objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

Tetapi jangan asal global.

Trade-off:

ModeBenefitRisk
Fail on unknownMenangkap spec drift cepatConsumer lama rusak ketika provider menambah field
Ignore unknownLebih compatibleBisa menyembunyikan typo/misrouted payload

Practical rule:

For external/provider evolution, consumers should ignore unknown fields.
For internal command validation, reject unknown fields if strictness matters.

Contoh:

Inbound public API command: strict unknown fields may prevent user mistakes.
Outbound integration response: tolerant consumer improves compatibility.

15. Required vs Optional Is a Semantic Decision

Jangan tandai field required hanya karena database column NOT NULL.

Required berarti:

A supported consumer may rely on this field always being present.

Optional berarti:

Consumer must handle absence/null without failure.

Kesalahan umum:

assignedOfficerId:
  type: string
required:
  - assignedOfficerId

Padahal case baru mungkin belum assigned.

Lebih benar:

assignedOfficerId:
  type: string
  nullable: true

Atau lebih eksplisit:

assignment:
  oneOf:
    - $ref: '#/components/schemas/AssignedCase'
    - $ref: '#/components/schemas/UnassignedCase'

Butuh keputusan domain:

Apakah unassigned case valid state?
Jika ya, modelkan eksplisit.

16. Event Schema Compatibility

Event contract berbeda dari HTTP.

HTTP request-response umumnya synchronous dan consumer langsung tahu error.

Event bersifat asynchronous:

producer emits event
consumer may process later
schema may evolve while old events still exist
consumer may replay historical events

Compatibility harus mempertimbangkan replay.

Pertanyaan desain:

Apakah consumer baru bisa membaca event lama?
Apakah consumer lama bisa membaca event baru?
Apakah event lama masih disimpan di Kafka/log/archive?
Apakah schema registry enforce compatibility?
Apakah event versioning ada di envelope atau schema registry?

Event rule:

Once emitted, an event is history.
Do not pretend you can change it.

17. Event Envelope Pattern

Gunakan envelope stabil:

{
  "eventId": "evt-1001",
  "eventType": "CaseEscalated",
  "schemaVersion": 2,
  "producer": "case-service",
  "occurredAt": "2026-07-02T10:15:30Z",
  "correlationId": "corr-77",
  "causationId": "cmd-55",
  "subjectId": "CASE-1001",
  "payload": {
    "caseId": "CASE-1001",
    "previousStatus": "UNDER_REVIEW",
    "currentStatus": "ESCALATED",
    "reasonCode": "SLA_BREACH"
  }
}

Envelope fields should be stable:

eventId: idempotency/deduplication
occurredAt: event time
producer: source service
correlationId: tracing across services
causationId: command/request that caused the event
subjectId: partitioning/query convenience
schemaVersion: compatibility handling
payload: domain data

Event contract tests should verify:

  • envelope required fields,
  • payload schema,
  • key/header convention,
  • semantic mapping from domain transition to event,
  • idempotency fields,
  • replay compatibility.

18. Avro/Protobuf Compatibility Thinking

Walaupun seri ini Java-focused, compatibility mental model penting.

18.1 Avro-style Thinking

Avro sering menekankan reader/writer schema resolution.

Practical ideas:

add field with default -> generally compatible
remove field that reader ignores -> may be compatible
rename needs alias strategy
changing type is risky

18.2 Protobuf-style Thinking

Protobuf compatibility bergantung pada field numbers.

Practical ideas:

never reuse field numbers
reserve removed field numbers/names
adding fields is usually okay
changing type can be dangerous
unknown fields matter

18.3 JSON-style Thinking

JSON tidak enforce field number/default schema resolution.

Maka governance harus lebih explicit:

schema diff
consumer contracts
unknown field policy
versioned envelope
backward-compatible parser
integration replay tests

19. Contract Testing for Message Consumers

Consumer event contract bisa diuji dari dua sisi.

19.1 Producer Contract Test

Producer test memastikan event yang dihasilkan sesuai schema/contract.

@Test
void emitsCaseEscalatedEventContract() {
    CaseAggregate caseAggregate = CaseAggregate.underReview("CASE-1001", "OFF-7");

    List<DomainEvent> events = caseAggregate.handle(
        new EscalateCase("CASE-1001", "SLA_BREACH", "OFF-7")
    );

    CaseEscalated event = onlyEvent(events, CaseEscalated.class);

    assertThat(event.caseId()).isEqualTo("CASE-1001");
    assertThat(event.previousStatus()).isEqualTo("UNDER_REVIEW");
    assertThat(event.currentStatus()).isEqualTo("ESCALATED");
    assertThat(event.reasonCode()).isEqualTo("SLA_BREACH");
}

19.2 Consumer Contract Test

Consumer test memastikan consumer bisa memproses message sesuai contract.

@Test
void consumesCaseEscalatedEventV2() {
    String message = """
        {
          "eventId": "evt-1001",
          "eventType": "CaseEscalated",
          "schemaVersion": 2,
          "occurredAt": "2026-07-02T10:15:30Z",
          "caseId": "CASE-1001",
          "previousStatus": "UNDER_REVIEW",
          "currentStatus": "ESCALATED",
          "reasonCode": "SLA_BREACH"
        }
        """;

    consumer.handle(message);

    assertThat(readProjection("CASE-1001").status()).isEqualTo("ESCALATED");
}

19.3 Replay Compatibility Test

Store old sample events as golden corpus.

src/test/resources/contracts/events/case-escalated-v1.json
src/test/resources/contracts/events/case-escalated-v2.json
src/test/resources/contracts/events/case-reopened-v1.json

Test:

@ParameterizedTest
@ValueSource(strings = {
    "contracts/events/case-escalated-v1.json",
    "contracts/events/case-escalated-v2.json"
})
void canReplaySupportedHistoricalEvents(String resource) {
    String eventJson = readResource(resource);

    consumer.handle(eventJson);

    assertProjectionConsistent();
}

This catches:

consumer parser got stricter
field removed from deserializer
unknown enum not handled
old event format forgotten
migration path broken

20. Versioning Strategy

Versioning is not a magic compatibility machine.

Bad strategy:

Whenever anything changes, create /v2.

This creates permanent duplication.

Better strategy:

Make compatible changes by default.
Version only for intentional semantic break.

20.1 Endpoint Versioning

/api/v1/cases
/api/v2/cases

Pros:

  • obvious,
  • easy routing,
  • easy docs separation.

Cons:

  • duplicate implementation,
  • migration burden,
  • consumer fragmentation.

20.2 Header Versioning

Accept: application/vnd.company.case.v2+json

Pros:

  • clean URL,
  • media-type semantics.

Cons:

  • less visible,
  • tooling sometimes harder,
  • gateway config more complex.

20.3 Schema Version in Event

{
  "eventType": "CaseEscalated",
  "schemaVersion": 2
}

Pros:

  • consumer can branch,
  • replay-friendly,
  • explicit.

Cons:

  • branching logic accumulates,
  • needs deprecation policy.

Versioning rule:

Version only when compatibility cannot be preserved safely.

21. Deprecation Policy

Compatibility requires lifecycle.

A field/endpoint/event cannot simply disappear.

Deprecation lifecycle:

A serious deprecation policy includes:

deprecation marker in spec
date announced
owner named
consumer list known
runtime usage telemetry
migration guide
contract verification passing for migrated consumers
removal review

Example OpenAPI marker:

assignedOfficerId:
  type: string
  nullable: true
  deprecated: true
  description: Use assignment.officerId. Will be removed after 2026-12-31.

But do not trust docs alone.

Add runtime telemetry:

Which consumers still request/use old representation?

For HTTP this may require:

  • client ID header,
  • API gateway access logs,
  • feature-specific endpoint logs,
  • response field usage is hard to observe directly.

For events:

  • consumer group lag,
  • schema version consumption,
  • dead-letter queue,
  • consumer registration,
  • replay compatibility tests.

22. Schema Diff Gates

CI should fail when contract changes are breaking.

Example pipeline:

Breaking detection examples:

removed path
removed method
removed response field
changed requiredness
changed type
changed enum
changed response status
changed content type

Do not rely only on tools. Tools cannot fully detect semantic break.

Example semantic break:

Field status still string enum,
but UNDER_REVIEW now means something different.

Only humans/domain tests/consumer tests can catch that.


23. Contract Test Placement in CI/CD

Recommended layers:

consumer PR:
  run consumer tests
  generate/update contract
  publish contract after merge

provider PR:
  run provider unit/component tests
  run OpenAPI compatibility diff
  verify provider against existing consumer contracts

provider deployment:
  check can-i-deploy style gate
  verify latest contract results
  deploy only if supported consumers safe

Provider should not deploy just because provider tests pass. Provider should deploy when:

provider tests pass
schema diff acceptable
consumer contracts verified
migration policy satisfied

24. Contract Tests vs Integration Tests

Do not confuse them.

Test TypeScopeSpeedMain SignalFailure Meaning
Unitclass/functionfastlocal logiccode behavior bug
Componentservice modulefast-mediummodule behaviorboundary adapter bug
Contractmessage between systemsmediumcompatibilityprovider/consumer mismatch
Integrationreal dependencymedium-slowinfrastructure behaviorDB/Kafka/HTTP integration issue
E2Ejourneyslowuser/system flowflow/env issue

Contract test does not prove provider works with database. Integration test does not prove all consumers are safe.

They answer different questions.


25. Case Study: Case Transition API

Domain:

A case can transition from UNDER_REVIEW to ESCALATED if SLA breached.
Dashboard consumer needs transition result and error code for invalid transitions.
Audit consumer needs event when transition happens.

25.1 Provider API Contract

paths:
  /cases/{caseId}/transitions:
    post:
      operationId: transitionCase
      parameters:
        - name: caseId
          in: path
          required: true
          schema:
            type: string
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TransitionCaseRequest'
      responses:
        '200':
          description: Transition accepted or idempotently replayed
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/TransitionCaseResponse'
        '409':
          description: Invalid transition
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Problem'

25.2 Request Schema

TransitionCaseRequest:
  type: object
  required:
    - action
    - actorId
  properties:
    action:
      type: string
      enum: [ESCALATE, CLOSE, REOPEN]
    reasonCode:
      type: string
      nullable: true
    actorId:
      type: string

Potential compatibility issue:

reasonCode nullable means server cannot later require it for all actions without breaking clients.

Alternative:

oneOf:
  - $ref: '#/components/schemas/EscalateCaseRequest'
  - $ref: '#/components/schemas/CloseCaseRequest'
  - $ref: '#/components/schemas/ReopenCaseRequest'

Better contract when commands have different required fields.

25.3 Response Schema

TransitionCaseResponse:
  type: object
  required:
    - caseId
    - previousStatus
    - currentStatus
    - transitionId
  properties:
    caseId:
      type: string
    previousStatus:
      type: string
    currentStatus:
      type: string
    transitionId:
      type: string
    idempotentReplay:
      type: boolean
      default: false

Adding idempotentReplay as optional/default is compatible. Removing previousStatus is breaking.


26. Consumer Contract Scenarios

Dashboard consumer might define:

Scenario: escalation succeeds
Given case CASE-1001 is under review
When POST /cases/CASE-1001/transitions with action ESCALATE
Then response is 200
And body.caseId = CASE-1001
And body.previousStatus = UNDER_REVIEW
And body.currentStatus = ESCALATED

Another scenario:

Scenario: cannot escalate closed case
Given case CASE-2001 is closed
When POST /cases/CASE-2001/transitions with action ESCALATE
Then response is 409
And body.code = INVALID_CASE_TRANSITION
And body.details.currentStatus = CLOSED

The error scenario is not optional. If consumer renders specific recovery instructions, error contract matters.


27. Contract Granularity

Do not create contract for every possible data combination.

Create contracts for:

consumer-visible behavior branches
required response shapes
important error branches
version compatibility boundaries
consumer-specific assumptions

Too few contracts:

only happy path -> false confidence

Too many contracts:

test suite becomes brittle and expensive

Good contract suite shape:

1-2 happy paths per consumed capability
1 contract for each machine-actionable error
1 contract for pagination/filtering if consumed
1 contract for auth/authorization if consumer branches on it
1 contract for idempotency if consumer relies on retry

28. Semantic Compatibility Checklist

Before changing an API/event, ask:

Which consumers exist?
Which fields do they use?
Which errors do they branch on?
Which enum values do they handle?
Which headers are required?
Which timeouts do they assume?
Which sorting/pagination behavior do they assume?
Which event versions can they replay?
Can old consumers read new provider messages?
Can new consumers read old messages?
Is migration observable?

This checklist catches more production bugs than arguing about REST purity.


29. Java Implementation Patterns

29.1 Keep API DTO Separate from Domain Model

Bad:

public class CaseEntity {
    public Long id;
    public String status;
    public String internalRiskScore;
    public String deletedFlag;
}

Returned directly as API response.

Problem:

Database changes become API changes.
Internal fields leak.
Compatibility becomes accidental.

Better:

public record CaseViewResponse(
    String caseId,
    String status,
    String assignedOfficerId,
    String priority
) {}

Mapping is explicit:

final class CaseViewMapper {
    CaseViewResponse toResponse(CaseRecord record) {
        return new CaseViewResponse(
            record.publicId(),
            record.status().name(),
            record.assignedOfficerId().orElse(null),
            record.priority().name()
        );
    }
}

DTO stability is API stability.

29.2 Version DTOs Intentionally

public record CaseViewV1(
    String caseId,
    String status,
    String assignedOfficerId
) {}

public record CaseViewV2(
    String caseId,
    String status,
    AssignmentView assignment
) {}

Avoid half-versioning:

public record CaseView(
    String caseId,
    String status,
    String assignedOfficerId, // deprecated but still used
    AssignmentView assignment
) {}

This may be acceptable temporarily, but requires removal policy.

29.3 Stable Enum Strategy

Java enum exposed as API is risky.

public enum CaseStatus {
    DRAFT,
    UNDER_REVIEW,
    ESCALATED,
    CLOSED
}

If you add:

SUSPENDED

Consumer may fail if it switches exhaustively.

Consumer should model unknown:

sealed interface RemoteCaseStatus {
    record Known(String value) implements RemoteCaseStatus {}
    record Unknown(String value) implements RemoteCaseStatus {}
}

Simpler version:

String status

Then validate known values at domain edge.

Producer governance:

Adding enum value is not automatically safe.
Treat it as semantic compatibility review.

30. Golden Samples

Golden samples are committed example messages that represent supported contracts.

Directory:

src/test/resources/contracts/http/cases/get-case-200.json
src/test/resources/contracts/http/cases/transition-case-409.json
src/test/resources/contracts/events/case-escalated-v1.json
src/test/resources/contracts/events/case-escalated-v2.json

Use golden samples to test:

serialization remains stable
deserialization remains tolerant
old events can replay
error body remains parseable
sample docs remain realistic

But beware snapshot testing anti-pattern:

Huge snapshots that nobody reviews carefully.

Golden sample should be:

  • small,
  • meaningful,
  • named by scenario,
  • reviewed like public API,
  • tied to compatibility policy.

31. Contract Testing Anti-Patterns

31.1 Contract Mirrors Implementation

Bad:

Contract generated from current provider implementation and immediately used to prove provider matches itself.

This catches almost nothing.

31.2 Consumer Contract Too Synthetic

Bad:

Consumer test creates contract for data it never uses.

It creates noise.

31.3 Provider Verification Uses Mock Provider

Bad:

Contract verification calls mocked controller response.

It proves the mock matches the contract, not the service.

31.4 Contract Ignores Errors

Bad:

Only 200 responses are contracted.

Real consumers often rely on 400/401/403/404/409/422/429/500 behavior.

31.5 No Ownership

Bad:

Contract failed; nobody knows whether consumer or provider should change.

Every contract should have consumer owner and provider owner.


32. Contract Review Heuristics

When reviewing API PR:

Is this field public or internal?
Is this name stable enough for years?
Can this field be null in real life?
Can this enum grow?
Can old clients ignore new fields?
Can old clients survive removed fields?
Is this error code machine-readable?
Is this behavior documented in contract?
Is migration observable?

When reviewing event PR:

Can old consumers read this event?
Can new consumers replay old events?
Are field defaults defined?
Are event IDs stable?
Is partition key unchanged?
Is ordering assumption documented?
Can duplicate events be processed safely?

When reviewing contract test PR:

Does this contract reflect real consumer behavior?
Is generated data matched flexibly?
Are stable semantics asserted exactly?
Are provider states deterministic?
Are error scenarios covered?

33. Contract and Formal Thinking

Contract testing is executable boundary specification.

Formal methods ask:

What properties must always hold?

Contract testing asks:

What external messages must remain valid for known collaborators?

They connect through invariants.

Example invariant:

A successful ESCALATE command must emit exactly one CaseEscalated event with the same caseId and a transitionId present in API response.

This can become:

  • domain unit test,
  • event contract test,
  • API contract test,
  • integration test,
  • production observability check.

Same property, different evidence.


34. Minimal Contract Governance Model

For a serious Java platform, use:

1. API specs live in repo and are reviewed.
2. Consumer contracts are published after consumer tests pass.
3. Provider verifies all supported consumer contracts in PR.
4. Breaking schema diffs require explicit version/migration plan.
5. Runtime telemetry informs deprecation/removal.
6. Golden event samples protect replay compatibility.
7. Error codes are treated as public API.
8. Contract ownership is explicit.

This is small enough to implement. Large enough to prevent many integration incidents.


35. Practical Exercise

Take any Java service you maintain.

Create a table:

BoundaryConsumerContract TypeCurrent ProtectionRisk
GET /cases/{id}dashboardHTTP/OpenAPI + Pactweakfield rename
CaseEscalatedaudit-serviceKafka eventsample onlyreplay break
POST /transitionsworkflow-uiHTTPunit tests onlyerror code drift

Then for each high-risk boundary:

1. Identify stable fields.
2. Identify optional fields.
3. Identify error codes.
4. Identify compatibility risks.
5. Add one contract test.
6. Add one schema diff gate.
7. Add one golden sample if event/file.

The goal is not 100% contract coverage. The goal is evidence where breakage is expensive.


36. Checklist

Before you consider this part mastered, you should be able to:

  • distinguish OpenAPI/provider spec from consumer-driven contract,
  • explain backward compatibility in old/new provider/consumer terms,
  • identify breaking vs compatible schema changes,
  • design stable DTOs separate from domain/entity models,
  • define error response as public API,
  • use provider state deterministically,
  • avoid exact matching for generated values,
  • create golden samples for event replay,
  • place contract verification in CI/CD,
  • review API changes with semantic compatibility in mind.

37. Key Takeaways

Contract testing is not about testing everything.
It is about preventing independent deployment from becoming integration roulette.
Schema validity is not enough.
Compatibility means supported consumers still behave correctly.
A field name is a long-term promise.
An error code is a long-term promise.
An event is history once emitted.
Good contract engineering combines provider spec, consumer contracts, schema diff, golden samples, runtime telemetry, and deprecation discipline.

38. References

Lesson Recap

You just completed lesson 15 in build core. Use the series map if you want to review the broader track, or continue directly into the next lesson while the context is still warm.

Continue The Track

Keep the momentum while the lesson is still fresh. Move backward for review or continue forward into the next concept.