What's Inside a Pact File
An annotated tour of the JSON document Pactify produces - the serializer settings that shape it, the snake_case field inherited from Pact v1, and the unusual decision to ship the consumer's matching options inside the contract itself.
Contract testing tools live or die by a single artifact: the pact file. It's the only thing that crosses the boundary between the consumer's pipeline and the provider's. Both sides can be rewritten, re-platformed, even re-owned by different teams — as long as that JSON document keeps its meaning, the seam stays tested.
So before touching the provider side of Pactify (the small open-source .NET contract testing library this series takes apart), I want to spend a whole post on the document itself. It's short, but almost every line encodes a design decision — one of them genuinely unusual.
The artifact, annotated
Run the consumer test from part 2 and this lands in the pacts folder as checkout-inventory.json:
{
"consumer": {
"name": "checkout"
},
"provider": {
"name": "inventory"
},
"interactions": [
{
"provider_state": "A SKU exists with stock on hand",
"description": "A GET request for a stock level",
"request": {
"method": "GET",
"path": "api/stock/ABC-123"
},
"response": {
"headers": {
"Content-Type": "application/json"
},
"status": 200,
"body": {
"sku": "00000000-0000-0000-0000-000000000000",
"warehouse": null,
"available": 0
}
}
}
],
"options": {
"ignoreCasing": true,
"ignoreContractValues": true
}
}
Four sections. consumer and provider name the two parties — they're also the file's identity, as we'll see. interactions is the list of recorded expectations, one entry per WithHttpInteraction call. And options… hold that thought.
The serializer is part of the contract
Everything about this document's shape comes from one small settings class:
// src/Pactify/Serialization/PactifySerialization.cs
public static JsonSerializerSettings Settings = new JsonSerializerSettings
{
ContractResolver = new CamelCasePropertyNamesContractResolver(),
NullValueHandling = NullValueHandling.Include,
Formatting = Formatting.Indented
};
Three choices, each defensible and each worth noticing:
CamelCase resolver — the C# PascalCase definition classes become idiomatic JSON. Your WithBody example objects get the same treatment, so a C# Available property is recorded as available. If your real API serializes differently (say, snake_case via a custom naming policy), the pact records a shape your provider never produces — and only the IgnoreCasing option (which, we'll discover in part 6, is more limited than its name suggests) stands between you and a false failure.
NullValueHandling.Include — this is why part 2's uninitialized warehouse: null appears at all. For a contract document it's the right call: “this property exists and may be null” is information. A serializer that omitted nulls would silently shrink the recorded contract to whichever properties happened to be non-null in the example.
Indented — pact files get read by humans during arguments between teams. Pretty-printing is a feature.
One field breaks the camelCase pattern deliberately:
// src/Pactify/Definitions/Http/HttpInteractionDefinition.cs
[JsonProperty(PropertyName = "provider_state")]
public string State { get; set; }
That snake_case provider_state is Pact heritage — it's exactly the field name from the original Pact specification v1. It's a small tell about the library's lineage: Pactify (by Dariusz Pawlukiewicz of DevMentors, MIT-licensed on GitHub) isn't inventing a format, it's approximating an established one.
How close is the approximation? Honest assessment: the skeleton — consumer/provider names, interactions with provider_state/description/request/response — matches Pact v1 closely enough that a Pact Broker will store and render these files happily (the README demonstrates exactly that, and it works). But there's no metadata.pactSpecification.version block, no matchingRules (Pact's mechanism for “any string here” / “match this regex” — Pactify has nothing comparable), and the options section is pure Pactify invention. Strict spec tooling — PactNet's verifier, can-i-deploy matrix logic that inspects pact internals — shouldn't be assumed to interoperate. Treat Pactify pacts as Pactify-to-Pactify documents that happen to be broker-compatible, and you'll never be surprised.
The body is an ExpandoObject in disguise
The response body property carries an attribute that quietly makes the whole verification side work:
// src/Pactify/Definitions/Http/HttpPactResponse.cs
[JsonConverter(typeof(ExpandoObjectConverter))]
public object Body { get; set; }
On the way out (consumer side), Body holds whatever object you passed to WithBody and serializes normally. But on the way in — when the provider's verifier deserializes the pact — that converter materializes the body as an ExpandoObject: a dictionary of property name to value, nested objects becoming nested ExpandoObjects.
That's a deliberate erasure. By the time verification happens, there is no StockLevelModel type anywhere — the provider might not even reference the assembly it lives in. The contract is compared as data against data: the verifier deserializes the provider's real HTTP response into another ExpandoObject and walks the two dictionaries. Types were only ever scaffolding for producing the document. This is the correct architecture for contract testing — the consumer's types must not be a dependency of the provider's build — and it's also, as part 6 will show, precisely where the matching engine's sharpest limitations come from (dictionary-vs-dictionary comparison does not recurse gracefully).
The filename is a primary key
Where does checkout-inventory.json come from?
// src/Pactify/Utils/PactifyUtils.cs
public static string CreatePactFilePath(string consumer, string provider, string localPath)
=> $"{localPath}/{consumer}-{provider}.json";
One file per consumer-provider pair, named by convention, and both sides compute the same name independently — the consumer to write it, the provider's RetrievedFromFile(path) to find it (that's why the verifier makes you call Between("checkout", "inventory") before it will even construct the file retriever). Publishing twice overwrites; the file is a snapshot, not a log.
Three practical consequences I've learned to respect. First, all interactions between a pair live in one document — you can't split “checkout's stock contracts” and “checkout's reservation contracts” into separate files against the same provider; they're one list. Second, service names with a hyphen work fine until you're parsing filenames in a script and order-service-inventory-api.json has four hyphens; pick naming conventions accordingly. Third, there's no version in the name — versioning is entirely the broker's job (part 4), and file-based workflows simply have a single “current” contract.
The unusual decision: options travel with the pact
Now the part I've never seen in another contract testing tool. The consumer's matching options — IgnoreCasing, IgnoreContractValues — are serialized into the pact document, and the provider's verifier reads them from there:
// src/Pactify/PactVerifier.cs — provider side
var definition = await _retriever.RetrieveAsync();
var resultTasks = definition.Interactions
.Select(c => verifier.VerifyAsync(c, definition.Options, _pathTemplateObject))
.ToList();
definition.Options — the options that came out of the JSON. The provider has no API for setting matching strictness. None. The consumer decided, at recording time, how strictly its own contract would be enforced, and that decision shipped inside the artifact.
I keep turning this decision over, because it cuts both ways.
The case for it: a contract with ambient, per-side configuration isn't really a contract. If the provider could locally set IgnoreContractValues = true while the consumer believed values were being checked, the two teams would hold different beliefs about what “verified” means — the exact disease contract testing exists to cure. Putting the dial inside the document makes enforcement semantics part of the agreement. One source of truth, no drift.
The case against: it's consumer-absolutist. The provider can't opt into stricter checking of its own behaviour, and more practically, a consumer that records with sloppy options degrades the guarantee for everyone downstream of that pact. There's also a subtle blast-radius issue: the options are document-wide, not per-interaction. You cannot say “check values for this critical pricing interaction, shape-only for the rest” — one dial governs the whole pact. In practice this pushed my teams toward IgnoreContractValues = true everywhere, because a single interaction that needed value-leniency forced the whole document lenient anyway.
Consumer-driven contract testing already inverts the power relationship — consumers declare, providers comply. Pactify's options-in-the-pact takes that philosophy one notch further than the mainstream tools do: the consumer even writes the rules of engagement. Once you see it, the README's line that the consumer “is always right” reads less like a slogan and more like an architecture document.
Next in the series: the document needs to get from the consumer's pipeline to the provider's. Files-in-a-folder works longer than you'd expect, brokers work better — and Pactify's HTTP publisher has a silent failure mode you'll want to know about before you rely on it.