Consumer Contract Tests Between Go Services That Scale
Catch breaking API changes between Go services with consumer-driven contracts: consumers write expectations, the provider replays them in CI.

Two services can each pass their own tests and still fail together. The provider renames a field, the consumer's tests use a hand-written fake that still has the old name, and nothing is red until production. End-to-end tests catch this, but they are slow, need both services running and tell you late. Contract tests catch the same break at the provider's build, in milliseconds.
Tested with Go 1.25.6, macOS on Apple Silicon (arm64)
This guide builds a small, hand-rolled version of consumer-driven contracts on Go 1.25.6 to show the idea without a tool, then maps it to Pact and to the alternatives. The article on API integration tests covers the layer above this one, and the shared contract article covers the schema-first alternative.
The idea in one paragraph
A contract is the part of the provider's interface that one consumer actually uses. The consumer writes it down: for this request, I expect this status and these fields with these types. The provider keeps the file and runs it in CI against its real handler. If the provider changes in a way that breaks the expectation, its own build fails, naming the consumer. The direction is the point. In the usual setup a provider documents everything it offers and consumers guess what is safe; here consumers state what they depend on, so the provider learns exactly which changes are safe, and which fields nobody reads.
The provider and the contract
package main
import (
"encoding/json"
"fmt"
"net/http"
)
// Expectation is what a consumer needs from one provider endpoint, written by the consumer.
type Expectation struct {
Name string `json:"name"`
Method string `json:"method"`
Path string `json:"path"`
Status int `json:"status"`
Required map[string]string `json:"required"` // field -> JSON type: string, number, bool, array, object
}
// Provider is the real service handler under verification.
func Provider() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /users/{id}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{
"id": r.PathValue("id"), "name": "Ada", "email": "[email protected]",
"plan": "pro", "created": "2026-01-02", // extra fields are fine for consumers
})
})
mux.HandleFunc("GET /orders/{id}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]any{"id": r.PathValue("id"), "total": "12.50"}) // total is a string
})
return mux
}
func typeOf(v any) string {
switch v.(type) {
case string:
return "string"
case float64:
return "number"
case bool:
return "bool"
case []any:
return "array"
case map[string]any:
return "object"
}
return fmt.Sprintf("%T", v)
}
func main() {}The provider has two endpoints. GET /users/{id} returns the user with extra fields (plan, created) that no consumer asked for. GET /orders/{id} returns an order whose total is the string "12.50".
The contract is a JSON file that the consumer, a billing UI here, wrote and committed:
[
{"name": "show a user", "method": "GET", "path": "/users/7", "status": 200,
"required": {"id": "string", "name": "string", "email": "string"}},
{"name": "show an order total", "method": "GET", "path": "/orders/9", "status": 200,
"required": {"id": "string", "total": "number"}}
]Each entry names a request, an expected status and the fields the consumer reads with their JSON types. It says nothing about fields the consumer ignores. That omission is deliberate: it is what lets the provider add fields without coordination, which is the most common change.
Replaying contracts
package main
import (
"encoding/json"
"net/http/httptest"
"os"
"path/filepath"
"testing"
)
// TestProviderHonoursContracts replays every consumer's expectations against the real handler.
func TestProviderHonoursContracts(t *testing.T) {
files, _ := filepath.Glob("contracts/*.json")
for _, f := range files {
raw, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
var exps []Expectation
if err := json.Unmarshal(raw, &exps); err != nil {
t.Fatal(err)
}
for _, e := range exps {
t.Run(filepath.Base(f)+"/"+e.Name, func(t *testing.T) {
rec := httptest.NewRecorder()
Provider().ServeHTTP(rec, httptest.NewRequest(e.Method, e.Path, nil))
if rec.Code != e.Status {
t.Fatalf("status %d, contract says %d", rec.Code, e.Status)
}
var body map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("body is not a JSON object: %v", err)
}
for field, want := range e.Required {
v, ok := body[field]
if !ok {
t.Errorf("field %q is missing", field)
continue
}
if got := typeOf(v); got != want {
t.Errorf("field %q is %s, consumer expects %s", field, got, want)
}
}
})
}
}
}F='s/ \([0-9.]+s\)//; s#^ +[a-z_]+\.go:[0-9]+: # #; s/\t[0-9.]+s$//'
go test -count=1 -v . 2>&1 | grep -vE '^(=== RUN|PASS)' | sed -E "$F"--- PASS: TestProviderHonoursContracts
ok example.test/labTestProviderHonoursContracts finds every file under contracts/, and for each expectation it builds a request, sends it to Provider() through httptest, and checks the status, that the body is a JSON object, and that every required field exists with the right type. The subtest names carry the file name and the expectation, so a failure says which consumer's which expectation broke.
The recorded run has one pass and one failure. show a user passes even though the response has two extra fields, which is exactly what should happen. show an order total fails with field "total" is string, consumer expects number. The provider chose to send money as a string, which is a defensible decision for precision, and the consumer wrote number in its contract, because that is what its parsing code assumes. One of them is wrong, and the failing test is the conversation starter: either the contract changes to string and the consumer's code changes with it, or the provider changes. The break was found in a unit test run, before either service was deployed.
What a contract should and should not say
A useful contract is loose in the right places.
- Say what you read. Fields, types, status codes, headers you depend on. Not the whole response.
- Say it with types, not values, unless the value is part of the interface: an enum member, a fixed status string. Matching on
"name": "Ada"ties the contract to the provider's test data. - Include the unhappy paths you handle: the
404body shape your UI parses, the422with field errors. Those are the responses that change without notice. - Do not describe behaviour. A contract says the endpoint returns a field called
total. Whether the total is right is the provider's own tests' job. Contract tests check shape and compatibility, not business logic.
Version the expectations with the consumer. When a consumer starts using a new field, it adds it to its contract in the same pull request that uses it, and the provider's CI picks the change up on its next run.
How it works across repositories
In the lab the contract file sits in the provider's repository. For separate repositories there are three workable layouts.
- The consumer opens a pull request adding or changing its file in the provider's
contracts/directory. Simple, reviewed by the provider team, and adequate for a handful of consumers. - The consumer publishes contracts as an artefact, such as a module, a container image or a file in object storage, and the provider's pipeline downloads the latest version of each. Add the consumer's version to the failure message.
- A broker holds them. The Pact Broker stores contracts and verification results, tracks which consumer version is deployed to which environment, and offers a
can-i-deploycheck. This is the scale answer: with twenty services you need to ask "can I deploy this version of the provider" and get an answer from data.
Pact itself is described here from its documentation and was not run for this article. It records interactions in a consumer test against a mock provider (which also keeps the consumer honest, because the consumer's tests run against what the contract says), writes a pact file, and verifies the file against a real provider. A Go implementation lives in the pact-foundation organisation. The hand-rolled version above has the same shape with less machinery, and no matchers, state handlers or broker.
Provider states and test data
The lab's provider returns the same data for every request, so there is nothing to set up. A real provider needs a user to exist before GET /users/7 can succeed. Pact calls this a provider state: the consumer says "given a user 7 exists", and the provider's verification code creates one. In a hand-rolled version, add a given field to each expectation and a map[string]func() of setup functions in the provider test. Keep states few, named in domain terms, and independent of each other.
Contracts and the other API tests
Contract tests do not replace the others. They complement them.
| Test | Checks | Cost |
|---|---|---|
| Handler unit test | The provider's logic, in process | Milliseconds |
| Contract test | The provider still serves what consumers read | Milliseconds, per consumer file |
| Schema / OpenAPI check | The provider matches its published description | Milliseconds |
| End to end | Real services, real network, real data | Seconds to minutes, flaky |
Consider the OpenAPI route if you have one. Generating the provider from the spec and the consumer's client from the same spec, as in the shared contract article, removes a class of drift without per-consumer files. Its weakness is that the spec describes everything the provider offers, so a consumer cannot tell which parts it depends on, and the provider cannot tell which fields are safe to remove. Consumer-driven contracts answer that second question.
Which changes a contract catches
The rules for evolving an API are well known, and a contract suite makes them mechanical. Against a consumer's contract, a provider change is safe or unsafe as follows.
| Provider change | Effect on a contract that lists the field |
|---|---|
| Add a field | Safe: the contract does not mention it |
| Remove a field | Fails: field "x" is missing |
| Rename a field | Fails, the same way |
| Change a type (string to number) | Fails, as in the recorded run |
| Change a status code | Fails: the status check |
| Make a field optional or nullable | Depends on the contract; a consumer that requires string fails on null |
| Tighten validation on a request | Not caught by shape checks; needs request bodies in the contract |
| Change meaning without changing shape | Not caught at all |
The last row is the honest limit. A provider that starts returning total in cents instead of dollars passes every shape check. Meaning lives in documentation and in the provider's own tests, and a contract that pins example values can sometimes catch it, with the cost of coupling to test data.
Deploying in the right order
Contracts also settle the order of deploys. A consumer that begins using a new field must be deployed after the provider that supplies it, and a provider that removes a field must be deployed after every consumer stopped reading it. With contracts in place, both facts are queries: the provider can see which consumers' files mention the field, and the consumer's pull request that adds the field fails the provider's verification until the provider ships it. Teams use this to run "expand and contract" migrations without a calendar. Expand the provider first, deploy, update the consumers, delete the contracts' references to the old field, and only then remove it.
Adopting it without a big project
Start with the one integration that has already broken. Ask that consumer to write the three or four requests it makes, in the format above, and add the replay test to the provider. The first day will find a mismatch, and that finding is the argument for adding the second consumer. Resist building a framework before you have three contracts. Keep the format boring: JSON files, a loop in a test, an error message that names the consumer and the field. Move to Pact when you have enough services that tracking versions by hand becomes the work, which for most teams is around ten services, not three.
Track one number: how many production incidents in the last quarter were a consumer broken by a provider change. If it is zero, spend the time elsewhere. If it is two, a few hundred lines of test code is cheap insurance, and the reading list in the APIs guide covers the rules of compatibility in more depth.
The consumer's side of the bargain
A contract the consumer writes by hand drifts. The reliable arrangement is to derive it from the consumer's own tests. The consumer's client code is tested against a stub of the provider, usually an httptest server that returns canned JSON. That canned JSON is already a statement of what the consumer believes the provider returns. Record the request and the fields the client touches while the test runs, write them to the contract file, and the file and the test cannot disagree. A field the client reads that is absent from the stub fails the consumer's test; a field in the stub that the client never reads does not appear in the contract.
That makes the consumer's stub the source of truth for the contract, so review the stub with the same care as the contract itself. A stub that returns "total": 12.5 when the real provider sends "12.50" produces a contract that is wrong in exactly the way the lab demonstrates, and the provider's replay is what discovers it. The first time a replay fails for this reason, the right response is a short conversation and a fix on one side, and not an argument about whose fault it is: the test did what it was built to do.
One repository, many services
In a monorepo the same technique is simpler. The contracts directory can sit next to the provider's package, the consumer's tests can write into it through a shared helper package, and a single go test ./... verifies every pair. The risk shifts from drift to coupling: with everything visible, it is tempting to import the provider's types in the consumer and skip the contract. Importing shared types removes the type mismatch but also the independence that lets services deploy separately, and it does not help when the consumer is written in another language. A JSON contract works across languages, which is the reason it is worth keeping even where Go types would do.
Limits
- Shape only. A contract that passes tells you the field exists and has a type. It cannot tell you the value is right, or that two endpoints agree.
- Only as complete as the consumers' diligence. A consumer that reads a field without writing it into the contract is unprotected. Generate the contract from the consumer's tests, as Pact does, so the two cannot diverge.
- It adds a process. Someone owns the contracts directory, and a broken contract needs a human conversation. The tooling does not remove that.
- Asynchronous messages (events on a queue) need the same treatment with message contracts, and the replay becomes "produce this event, check these fields".
For the underlying protocol rules, the Pact documentation explains consumer-driven contracts and the broker, the OpenAPI specification is the schema-first alternative, and the Go side of the handler testing is covered in the httptest recorder article.


