The Consumer Writes the Contract
A walk through Pactify's PactMaker fluent API - the two-interface builder trick that hides Build() from you, why the consumer test always passes, and the uninitialized-object surprise inside WithBody<T>().
The first time I ran a Pactify consumer test, I spent a confused minute looking for the assertion. The test called my dependency's API… nowhere. It didn't spin up a mock server, didn't intercept an HttpClient, didn't assert a single thing about my own code. It just wrote a file and passed.
That confusion is worth resolving properly, because it's the heart of consumer-driven contracts: the consumer test isn't testing — it's recording. This post walks through the consumer half of Pactify (the open-source .NET contract testing library from DevMentors that this series dissects): the fluent API you write, the builder machinery underneath it, and one genuinely surprising implementation choice you need to know about before you trust the file it produces.
The whole consumer side in one expression
Here's a consumer test for a checkout service that depends on an inventory API:
[Fact]
public async Task Checkout_Records_Its_Contract_With_Inventory()
{
var options = new PactDefinitionOptions
{
IgnoreContractValues = true,
IgnoreCasing = true
};
await PactMaker
.Create(options)
.Between("checkout", "inventory")
.WithHttpInteraction(cb => cb
.Given("A SKU exists with stock on hand")
.UponReceiving("A GET request for a stock level")
.With(request => request
.WithMethod(HttpMethod.Get)
.WithPath("api/stock/ABC-123"))
.WillRespondWith(response => response
.WithHeader("Content-Type", "application/json")
.WithStatusCode(HttpStatusCode.OK)
.WithBody<StockLevelModel>()))
.PublishedAsFile("../../../../../pacts")
.MakeAsync();
}
Read it as a sentence, because that's how the API is designed: between checkout and inventory, given a SKU exists, upon receiving a GET to this path, inventory will respond with 200 and this body — publish that as a file. WithHttpInteraction can be chained repeatedly, one call per interaction the consumer relies on; they accumulate into a single pact document.
The vocabulary is inherited straight from Pact.io. Given names the provider state — the world the provider must arrange before this request makes sense. UponReceiving describes the request in human terms; it becomes the interaction's identity in the file and in failure messages. Both are strings, and in Pactify (spoiler for part 5) they are only strings — Given is never executed by anything. Write them as documentation for the provider team, because that is what they are.
Notice also what you can't say. The request builder's entire public surface is:
public interface IHttpPactRequestBuilder
{
IHttpPactRequestBuilder WithMethod(HttpMethod method);
IHttpPactRequestBuilder WithPath(string path);
}
Method and path. No request headers, no query-string matching rules, no request body. For GET-shaped read APIs that's most of what you need; for “the consumer POSTs this payload” contracts it's a real ceiling, and I'll be honest about the consequences when we reach the verifier. Contrast PactNet, where request bodies and matchers are first-class. Pactify chose radical simplicity here, and you should choose it knowing that.
The builder trick worth stealing
The fluent API has a design detail I've since reused in my own libraries. Each builder implements two interfaces: the public fluent one you see above, and an internal one that exposes the result:
// src/Pactify/Builders/IBuildingAccessor.cs
internal interface IBuildingAccessor<out TResult>
{
TResult Build();
}
internal sealed class HttpInteractionBuilder
: IHttpInteractionBuilder, IBuildingAccessor<HttpInteractionDefinition>
{ ... }
When PactMaker needs the finished definition, it casts:
// src/Pactify/PactMaker.cs
var builder = new HttpInteractionBuilder();
buildCoupling(builder);
var accessor = (IBuildingAccessor<HttpInteractionDefinition>)builder;
var definition = accessor.Build();
_pactDefinition.Interactions.Add(definition);
The payoff: IntelliSense inside your lambda shows only the fluent verbs. You can't call Build() half-way through, can't extract a partially-constructed interaction, can't do anything except describe the contract. The concrete builders are internal sealed; the definitions they produce (PactDefinition, HttpInteractionDefinition, HttpPactRequest, HttpPactResponse) are internal too. The public API is verbs only — the nouns are hidden. For a library whose whole job is guiding you into producing a well-formed document, that's exactly the right amount of restriction.
Validation happens eagerly at each step with a dedicated exception type — Between throws a PactifyException if either name is empty, Given rejects empty state, WithPath rejects an empty path. You find out you've written a nonsense contract at build time of the definition, not when a provider fails to verify it three pipelines away.
The WithBody<T>() surprise
The response builder gives you two ways to declare the expected body:
IHttpPactResponseBuilder WithBody(object body); // pass an instance
IHttpPactResponseBuilder WithBody<TBody>(); // pass a type
The generic overload looks like the obviously-correct choice — “the response body is a StockLevelModel” — and it's what the README uses. But look at how it's implemented:
// src/Pactify/Builders/Http/HttpPactResponseBuilder.cs
public IHttpPactResponseBuilder WithBody<TBody>()
=> WithBody(FormatterServices.GetUninitializedObject(typeof(TBody)));
GetUninitializedObject is the serialization-infrastructure API that allocates an object without running any constructor — and that includes property initializers. Take this model:
public class StockLevelModel
{
public Guid Sku { get; set; } = Guid.NewGuid();
public string Warehouse { get; set; } = "MAIN";
public int Available { get; set; }
}
The pact file will record its body as:
"body": {
"sku": "00000000-0000-0000-0000-000000000000",
"warehouse": null,
"available": 0
}
Every value is the CLR zero-value. The Guid.NewGuid() never ran; "MAIN" never got assigned. The first time I saw a pact full of zero-GUIDs and nulls I assumed a serialization bug — it's not, it's GetUninitializedObject doing exactly what it does. (Pactify's own README shows an all-zeros GUID in its example pact for precisely this reason, though it doesn't call it out.)
Whether this matters depends entirely on one option. Pactify's matching has two switches, set at Create(...) time:
public class PactDefinitionOptions
{
public bool IgnoreCasing { get; set; }
public bool IgnoreContractValues { get; set; }
}
With IgnoreContractValues = true, verification checks only that each recorded body property exists in the provider's real response — values are ignored, so zero-values are harmless and WithBody<TBody>() is the perfect low-ceremony choice. That's the mode I run in almost always: structural contracts age far better than value contracts.
With IgnoreContractValues = false, the provider's response must contain those exact values — and no real API returns a zero GUID and a null name. In that mode you must use the non-generic overload with a deliberately-constructed example that the provider will seed:
.WithBody(new StockLevelModel
{
Sku = new Guid("9b5055dd-5750-4c65-ab4c-9a785a9b7ef4"),
Warehouse = "MAIN",
Available = 12
})
You can also pass an anonymous object, which I've come to prefer for contracts: new { sku = "...", available = 12 } records only the properties the consumer actually reads, instead of every property the shared model happens to have. The narrower the recorded contract, the more freedom the provider keeps — declaring fields you don't use is how consumers accidentally veto provider refactorings.
Passing isn't the point
Back to my opening confusion. The terminal step:
public async Task MakeAsync()
{
if (_publisher is null)
throw new PactifyException(ErrorMessages.PublisherNotSetUp);
await _publisher.PublishAsync(_pactDefinition);
}
Serialize the definition, hand it to whichever publisher you configured (PublishedAsFile or PublishedViaHttp — part 4's subject), done. No HTTP call to the provider, no mock, no assertion. Pactify's README says this part “should always pass,” and the source shows why: the only failure modes are a malformed definition or a publish error.
That still bothers some people — a test that can't fail! Two answers. First, in the fuller Pact.io workflow the consumer test does assert: you run your real client code against a mock server generated from the pact, proving the consumer can handle what it claims to expect. Pactify skips that half; your recorded expectations are declarations, so it's on you to keep them honest (pointing WithBody at the same model type your deserialization code uses is the cheap way). Second — the pass/fail you care about was never here. It happens in the provider's pipeline, when these recorded interactions get replayed against the real API. The consumer test's job is only to make that reckoning possible.
Next up: the artifact itself. The JSON that MakeAsync produces has some genuinely interesting decisions inside — including the consumer's matching options travelling inside the contract document, which means the consumer doesn't just write the contract, it sets the strictness of its own enforcement.