Publishing Pacts: From Shared Folder to Broker
How a pact travels from the consumer's pipeline to the provider's - Pactify's file and HTTP publishers, Pact Broker URL anatomy, and the unchecked response status that makes a failed publish silently pass your build.
A contract that only one side can see is a diary, not a contract. Everything interesting about consumer-driven contract testing happens after the pact file exists: it has to travel from the consumer's pipeline into the provider's, reliably, with both sides agreeing on which version is current.
Pactify — the compact open-source .NET contract testing library this series dissects — ships exactly two transport mechanisms, a file publisher and an HTTP publisher, mirrored by two retrievers. Together they're maybe a hundred lines of code, which means we can read all of it, including the one omission I'd rank as the most production-relevant finding in the whole library.
Stage one: a folder and a naming convention
The simplest handoff is a directory both test projects can reach:
// consumer test
.PublishedAsFile("../../../../../pacts")
// provider test
.RetrievedFromFile("../../../../../pacts")
That eyebrow-raising ../../../../../ is doing something worth naming: test code executes from bin/Debug/<tfm>/, so five levels up climbs out of the test project entirely — into the solution or repo root, where a pacts/ folder can sit as a sibling of both services. In a monorepo this is genuinely all you need. Consumer test writes checkout-inventory.json, provider test reads it, and the “publish step” is a git commit; the pact is versioned with the code, reviewed in pull requests, and the provider verifies whatever contract is on the branch. I ran a two-team system this way for months, happily.
The publisher itself is as small as you'd hope:
// src/Pactify/Publishers/FilePactPublisher.cs
public async Task PublishAsync(PactDefinition definition)
{
Directory.CreateDirectory(_localPath);
var filePath = PactifyUtils.CreatePactFilePath(definition, _localPath);
using (var file = File.CreateText(filePath))
{
var json = JsonConvert.SerializeObject(definition, PactifySerialization.Settings);
await file.WriteAsync(json);
}
}
Directory.CreateDirectory is idempotent, File.CreateText truncates — publishing is an unconditional overwrite of the single {consumer}-{provider}.json snapshot. No history, no merge; last writer wins. Within one repo, git supplies the history. The retriever is the mirror image: build the same conventional path, read, deserialize.
The file approach dies the day the consumer and provider live in different repositories with different pipelines. Now “a folder both sides can reach” means network shares or artifact-copying steps, the pact stops being reviewable next to the change that produced it, and nobody can answer “which version of checkout's contract did inventory verify against?” That question is what a broker exists to answer.
Stage two: the Pact Broker
The Pact Broker is the Pact ecosystem's contract registry — an HTTP server with a UI that stores pacts per version, shows who depends on whom, and renders the interactions in a browsable form. Pactify's README walks through running the standard Docker image (pact_broker-docker, a docker-compose up -d and it's live on localhost:9292), and its own test suite publishes there.
What Pactify actually gives you is not broker-specific — it's a generic HTTP publisher where you supply the URL and verb:
// consumer: publish this pact as checkout version 1.2.104
.PublishedViaHttp(
"http://localhost:9292/pacts/provider/inventory/consumer/checkout/version/1.2.104",
HttpMethod.Put)
// provider: always verify the latest contract
.RetrievedViaHttp(
"http://localhost:9292/pacts/provider/inventory/consumer/checkout/latest")
The broker's URL scheme is the API, and it's worth internalizing: /pacts/provider/{provider}/consumer/{consumer}/version/{consumerVersion} — a PUT there registers this document as that consumer version's contract. The version in the URL is the consumer application's version (use your build number or git SHA), not a contract version; the broker derives contract evolution by diffing content across consumer versions. /latest on the retrieval side is the simplest possible provider policy — always verify the newest. Mature broker workflows get more surgical (verify the version currently in production, gate deploys on the verification matrix via can-i-deploy), but none of that machinery lives in Pactify; you get exactly “PUT a document, GET a document,” and honestly, that's enough to start.
Reading the publisher closely
Here's the HTTP publisher, nearly whole, because three of its details matter:
// src/Pactify/Publishers/HttpPactPublisher.cs
public async Task PublishAsync(PactDefinition definition)
{
var json = JsonConvert.SerializeObject(definition, PactifySerialization.Settings);
var request = new HttpRequestMessage
{
RequestUri = new Uri(_url),
Content = new StringContent(json, Encoding.UTF8, JsonContentType),
Method = _method
};
if (!(_apiKey is null))
{
request.Headers.Add(RequestHeader, _apiKey); // "Pact-Requester"
}
await new HttpClient().SendAsync(request);
}
First: the response status is never checked. No EnsureSuccessStatusCode(), no reading the response at all. If the broker returns 401 because credentials rotated, 409 because of a conflict, or 500 because it's down, PublishAsync completes normally and your consumer test passes. This is, in my judgment, the sharpest edge in the library: the one step whose whole purpose is crossing a network boundary is the one step that can't fail. I learned this the practical way — a broker container was down for a week, every consumer build stayed green, and the provider spent that week diligently verifying a stale contract. If you adopt Pactify with a broker, wrap the publish or follow it with a cheap GET-it-back check in the pipeline. (It would also be a lovely first pull request to the project — this is a young, small OSS codebase, and that's an invitation, not a criticism.)
Second: authentication is a custom header. The API key travels as a Pact-Requester header — a Pactify convention, not a broker one. Real Pact Broker deployments authenticate with basic auth or bearer tokens on the Authorization header, which this publisher has no hook for. Against an unsecured internal broker (the Docker default) none of this matters; against a secured or hosted broker, you'd front it with something that understands the header, or bring your own HTTP step. The same header appears symmetrically in HttpPactRetriever, so Pactify-to-Pactify setups can at least build a shared-secret check around it.
Third: new HttpClient() per publish. The textbook .NET socket-exhaustion smell. In fairness, context is everything — this runs once per test run, not per request in a hot path, so it will never hurt you in practice. But it's a nice reminder of what this library is: direct, readable code that optimizes for being understood over being hardened.
The retriever mirrors the publisher — GET the URL, optional Pact-Requester header, deserialize the body with the same settings. It also skips status checking, but there the failure is at least loud: a 404 body doesn't deserialize into a PactDefinition, so the verifier blows up rather than silently passing.
Which stage are you at?
My rule of thumb after running both modes:
- Same repo, same cadence: use the file publisher and commit the
pacts/folder. You get review, history, and atomic consumer+contract changes for free. Don't deploy a broker to solve a problem git already solves. - Different repos or different cadences: you need the broker, not because HTTP is better than files, but because versioning becomes the hard part — “which contract was verified against which provider build” is a question that needs a database, and the broker is that database with a UI.
- Either way: put the pact publish step after the consumer's test suite in the pipeline, version the URL with your build identifier, and — with Pactify specifically — verify the publish landed, because the library won't tell you it didn't.
The pact is now sitting where the provider can reach it. Next post, the provider picks it up: PactVerifier, the TestServer trick that replays your entire contract against the real API without a single network socket, and the discovery that Given(...) — the provider-state string we wrote so carefully in part 2 — is never executed by anyone.