Reach for this skill when two or more independently deployed services integrate and you want integration confidence at unit-test speed, not via a fragile end-to-end stack:
-
Pick consumer-driven (Pact) when consumers know what they need; bi-directional/spec-driven when the provider already owns an OpenAPI/GraphQL spec. They are not interchangeable:
| Approach | How it works | Use when | Limitation |
|---|
| Consumer-driven (Pact) | consumer's tests generate expectations; provider replays them against the real app | consumers drive the API; you want to know exactly which fields are used | provider must run verification against real code; needs provider states |
| Bi-directional (PactFlow) | provider's OpenAPI is verified as a "provider contract"; consumer pacts compared statically against it — provider need not run | provider already has a trustworthy spec; can't run full provider verification | only as good as the spec; a spec that lies passes |
| Spring Cloud Contract | contracts in Groovy/YAML DSL live with the provider; generate provider tests + a stub jar consumers run against | JVM-heavy estate, provider-owned contracts, message + HTTP | JVM-centric; less natural for polyglot consumers |
Default to consumer-driven Pact for polyglot HTTP/message estates; Spring Cloud Contract for an all-JVM shop; add bi-directional when a provider can't feasibly run verification but has a real OpenAPI.
-
Write the consumer test against a Pact mock — assert on the request you send and matchers (not literals) for the response. The consumer test spins up Pact's local mock server, you exercise your real client code against it, and Pact records the interaction. Use matchers so the contract pins structure/type, not brittle example values:
const { PactV3, MatchersV3: M } = require('@pact-foundation/pact');
const provider = new PactV3({ consumer: 'web-bff', provider: 'orders-api' });
provider
.given('order 42 exists')
.uponReceiving('a request for order 42')
.withRequest({ method: 'GET', path: '/orders/42',
headers: { Accept: 'application/json' } })
.willRespondWith({ status: 200,
headers: { 'Content-Type': M.regex('application/json.*', 'application/json') },
body: { id: M.integer(42), total: M.decimal(19.99),
status: M.regex('PAID|PENDING', 'PAID'),
items: M.eachLike({ sku: M.string('ABC'), qty: M.integer(1) }) } });
await provider.executeTest(mock => new OrdersClient(mock.url).getOrder(42));
Rules: assert only on fields the consumer actually reads (Pact verifies the provider returns at least these — extra provider fields are fine; that's how providers stay free to add). Use integer/decimal/string/regex/eachLike/like, never hardcoded values, or any data change reds the provider. One given(...) per distinct precondition; the string must match a provider state handler exactly.
-
Run the consumer test in normal unit CI; it emits a pact JSON file as a side effect — there is no provider involved here. npm test / mvn test / pytest produces pacts/web-bff-orders-api.json. This runs at unit speed, no network, no provider deployed. The pact file is the deliverable.
-
Publish the pact to a broker, tagged with the consumer's git SHA + branch + (later) environments. The broker is the exchange point; never email pact files around.
pact-broker publish ./pacts \
--consumer-app-version $(git rev-parse --short HEAD) \
--branch $GIT_BRANCH \
--broker-base-url $PACT_BROKER_URL --broker-token $PACT_BROKER_TOKEN
Version MUST be the git SHA (or <semver>+<sha>), not a timestamp or "latest" — can-i-deploy reasons about specific versions, and a non-unique version corrupts the matrix. --branch enables WIP/pending-pact selection. Self-host the OSS Pact Broker (Docker, Postgres-backed) or use hosted PactFlow (adds bi-directional + WIP UI).
-
Provider verification: replay every consumer's pact against the real running provider, seeding data via provider-state handlers. The provider pulls pacts from the broker by consumer version selectors (not "all pacts ever") and runs them against a real instance:
@Provider("orders-api")
@PactBroker(url="${PACT_BROKER_URL}", selectors = {
@VersionSelector(deployedOrReleased = true), // pacts live in any env
@VersionSelector(mainBranch = true) })
class OrdersApiPactTest {
@State("order 42 exists")
void seedOrder42() { db.insertOrder(42, "PAID"); }
@TestTemplate @ExtendWith(PactVerificationInvocationContextProvider.class)
void verify(PactVerificationContext ctx) { ctx.verifyInteraction(); }
}
Verify against the real app + a test DB, not mocks — the point is to prove the actual provider satisfies the expectation. @State handlers are mandatory and must be idempotent; they set up exactly the data the interaction needs and clean up after. A missing/misnamed state handler fails verification with "state not found".
-
Publish verification results back to the broker so the matrix is complete on both sides. Set pact.verifier.publishResults=true (pact-jvm) / publishVerificationResult: true (pact-js) only in CI, keyed to the provider's git SHA. This is what lets can-i-deploy answer "has provider@sha verified consumer@sha?" — without it the matrix has holes and the gate fails open or stuck.
-
Gate every deploy with can-i-deploy against the target environment — this is the whole payoff. Before shipping either side, ask the broker whether this version is compatible with everything currently in the target env:
pact-broker can-i-deploy \
--pacticipant orders-api --version $(git rev-parse --short HEAD) \
--to-environment production --retry-while-unknown 30 --retry-interval 10
--retry-while-unknown waits for in-flight verifications instead of failing on a race. After a successful deploy, record it so the matrix tracks what's live:
pact-broker record-deployment --pacticipant orders-api \
--version $(git rev-parse --short HEAD) --environment production
Use record-deployment for environments you replace-in-place (one version live), record-release/record-support-ended for things like mobile apps where multiple versions are live at once — that's how you stop the provider dropping a field old app builds still need.
-
Trigger provider re-verification automatically on contract change via broker webhooks. Configure a broker webhook on contract_content_changed / contract_requiring_verification_published to POST to the provider's CI (GitHub Actions repository_dispatch, GitLab pipeline trigger). New consumer expectation published → provider pipeline runs verification → result published → consumer's can-i-deploy unblocks. Without this the loop is manual and contracts rot.
-
Use pending pacts + WIP pacts so a new/changed consumer expectation can't red the provider's main build. Enable enablePending: true and includeWipPactsSince: <date> in the provider's selectors. A brand-new consumer expectation is verified but reported as pending — failures are visible but non-blocking for the provider — until it verifies green once, at which point it becomes blocking. This decouples teams: a consumer can publish a forward-looking contract without breaking the provider's release, and the provider opts in when ready. Pair with branch-based selectors so you verify against main + deployedOrReleased, not every stale feature-branch pact.
-
For async/messaging, use message pacts; for the provider's own spec, optionally add a bi-directional contract. Message pacts: the consumer asserts on a message body it can handle (no HTTP mock); the provider verifies its producer function emits a matching message — same broker, same can-i-deploy. Bi-directional: publish the provider's OpenAPI as a provider contract (pactflow-cli publish-provider-contract openapi.yaml); PactFlow statically cross-validates consumer pacts against it, so the provider needn't run verification — accept the tradeoff that a wrong spec passes (mitigate by also asserting the spec in the provider's own tests).
Done = consumers generate matcher-based pacts at unit speed, the provider replays them against the real app with idempotent state handlers, verification results and deployments are recorded to the broker keyed by git SHA, every deploy is gated by can-i-deploy against the target environment, new expectations land as non-blocking pending pacts, and contract changes auto-trigger provider re-verification via webhook — proven by the breaking-change-blocks / additive-change-passes / pending-doesn't-red tests in checks 4–6.