1 Why API Breaking Changes Are Dangerous in Microservice Architectures
A Product API deployment can pass every test owned by its team and still break checkout. The implementation works, the database migration succeeds, and the endpoint returns valid JSON. The problem is that another service interprets that JSON differently from what the provider now sends.
Consumer-driven contract testing addresses this gap by making each consumer’s assumptions executable before a release reaches production.
1.1 The Challenge of Independently Deployed Microservices
Independent deployment creates a versioning problem. Production rarely contains the latest commit of every service. It contains combinations of releases, sometimes across regions, tenants, and deployment slots. A provider must work with the consumers actually running, including versions retained for rollback.
Consider an Order Service that retrieves a product and multiplies price by quantity. The Product team renames that response property to unitPrice. Their unit tests pass because their response DTO and assertions changed together. Their integration tests pass because the endpoint returns the newly documented shape.
The Order Service still expects price. Depending on its deserialization settings, it might throw, receive a default decimal value of zero, or fail later during order validation. The most dangerous outcome is a plausible but incorrect result rather than an obvious exception.
A shared staging environment helps, but it usually tests whichever versions happen to be deployed there. That combination may differ from production. Requiring every team to deploy to staging together also brings coordinated releases back through the side door.
The architectural requirement is specific: establish compatibility between identifiable application versions without starting the entire distributed system for every change.
1.2 Understanding API Contracts and Breaking Changes
An API contract includes the HTTP method, route, query parameters, request and response bodies, status codes, headers, and observable behavior that callers rely on. Serialization details count. So do assumptions such as whether an unknown identifier returns 404 or an empty object with 200.
Here is the original response:
{"productId":101,"price":125.50,"availability":"inStock"}
Adding a property is generally compatible with consumers that ignore unknown fields:
{"productId":101,"price":125.50,"availability":"inStock","category":"accessories"}
Replacing a consumed property breaks the existing contract:
{"productId":101,"unitPrice":125.50,"availability":"inStock"}
Changing price to null is also destructive if callers require a number. Changing a number into a numeric string can break strict deserializers even when the displayed value looks identical. An additive enum value can break a consumer with an exhaustive switch.
Compatibility is directional. A provider accepting an additional optional request field usually preserves existing callers. Making that field mandatory does not. A provider returning an extra response field often works; returning fewer required fields does not. These are defaults for reasoning, not universal guarantees: strict JSON validation can reject additions too.
Some breaking changes preserve the wire shape entirely. If price switches from major currency units to minor units, both versions remain numeric. Contracts need meaningful examples and appropriate assertions, but broader business tests must still protect monetary semantics.
1.3 Contract Testing vs. Unit, Integration, and End-to-End Testing
Each testing layer answers a different question:
| Test | Main question | Useful boundary |
|---|---|---|
| Unit | Does this logic behave correctly? | Class, function, policy |
| Integration | Do real components work together? | API with database, authentication, or broker |
| Contract | Can these particular applications communicate as expected? | Consumer-provider boundary |
| End-to-end | Does a complete user journey succeed? | Deployed workflow across services |
An integration test can check compatibility when it exercises the correct consumer and provider versions. The difficulty is maintaining that version coverage across independently deployed services. Testing the provider against its own DTO is insufficient evidence about another application’s expectations.
Contract tests belong near the broad base of a practical testing strategy: focused, repeatable checks that run frequently. They are generally cheaper than starting a complete environment, although provider verification can involve a real database and meaningful startup cost.
Keep integration tests for database mappings, authentication configuration, and transport behavior. Keep a smaller set of end-to-end tests for checkout, cancellation, and other important journeys. Pact does not measure latency under load, prove authorization, or establish that a sequence of operations is transactionally correct.
1.4 Consumer-Driven Contract Testing Explained
The consumer defines an interaction describing what it sends and what it needs back. Pact starts a mock HTTP server implementing that interaction. The test calls the consumer’s real HTTP client, including its request construction and deserialization. Successful execution generates a contract artifact.
Provider verification reverses the arrangement. Pact reads the artifact, prepares a declared provider state, sends the recorded request to the real provider, and compares its response against the consumer’s matching rules.
The lifecycle is Define → Generate → Publish → Verify → Deploy. Publication connects a consumer version to its contract. Verification connects a provider version to its result. Deployment checks use that evidence to evaluate the versions relevant to an environment.
This division avoids sharing executable consumer test code with the provider. It also avoids making consumers import the provider’s DTO assembly. Each application owns its implementation; the contract captures their agreement at the boundary.
2 Designing a Consumer-Driven Contract Testing Architecture in .NET 10
The example uses one provider and two consumers with deliberately different requirements. That difference matters: a breaking change should identify the affected relationship, rather than treating the entire API as one indivisible contract.
2.1 Real-World Architecture: Product, Order, and Shipping Microservices
Product API owns catalog information and exposes GET /api/products/{id}. Order Service retrieves pricing and availability before constructing an order line. Shipping Service retrieves product identification and availability before preparing fulfillment work.
Both call Product API independently. Neither consumer owns the catalog database, and Shipping does not call Order to discover product information.
The initial response for product 101 is:
{
"productId": 101,
"name": "Wireless Keyboard",
"price": 125.50,
"currency": "USD",
"availability": "inStock"
}
Order requires productId, price, currency, and availability. Shipping requires only productId and availability. Currency belongs in the pricing contract because a decimal without its unit is insufficient for an order calculation.
Availability here is a catalog signal, not an inventory reservation. A product can become unavailable between lookup and purchase. Reservation and overselling behavior require separate business operations and integration tests.
Use stable Pact participant names: Order Service, Shipping Service, and Product API. Names identify deployable applications, not test projects, branches, or individual endpoints.
2.2 Selecting the .NET Contract Testing Technology Stack
Use .NET 10 LTS and ASP.NET Core 10 for this implementation. PactNet 5.0.1 provides the concrete 5.x baseline used by the examples, including Pact specification v4 and System.Text.Json serialization. Keep runtime and Microsoft package patches current within the supported 10.0 line.
| Concern | Choice | Reason |
|---|---|---|
| HTTP consumer | Typed HttpClient | Exercise production request and parsing code |
| Test runner | xUnit v2 | Straightforward async tests and fixtures |
| Contract library | PactNet 5.0.1 | Consistent v4 interaction APIs |
| Registry | Open-source Pact Broker with PostgreSQL | Version, branch, and deployment evidence |
| API description | Microsoft.AspNetCore.OpenApi 10.0.x | OpenAPI 3.1 generation |
| Specification comparison | oasdiff | Detect declared compatibility changes |
| Automation | GitHub Actions and Docker | Repeatable verification and release gates |
FluentAssertions is optional; xUnit assertions keep the examples lean. Review the license of the FluentAssertions version you choose before introducing it into a commercial codebase.
PactNet uses native components. Run verification on a supported platform, such as an Ubuntu x64 runner with glibc. Do not assume an Alpine-based test container will work merely because the application itself runs there.
2.3 Understanding Pact Contracts, Matching Rules, and Provider States
A generated Pact file contains consumer and provider identities, interactions, provider states, and specification metadata. Each HTTP interaction includes a request, a response example, and any matching rules. The examples let Pact serve concrete responses; the rules define acceptable provider variation.
Use exact matching for values whose identity matters, such as the product requested in a deterministic state. Use numeric matching for a price when the consumer requires a JSON number. Use a regex for a deliberately bounded string domain. Collection matchers can express element structure and minimum size without fixing the entire array.
A provider state describes a precondition: product 101 exists or product 101 is missing. It is neither a previous interaction nor a call sequence. Every interaction must work independently after its state is established.
Do not copy the whole provider response into every consumer test. Shipping should not constrain prices simply because prices appear in the API. Conversely, omitting a field that Order actually reads leaves a hole in the contract.
2.4 Pact vs. OpenAPI: Two Complementary Approaches
OpenAPI describes the provider’s published surface. Pact describes requirements demonstrated by particular consumers. An OpenAPI diff can find a removed endpoint that no registered Pact currently exercises. Pact can find a runtime serialization mismatch even when the specification has not changed.
ASP.NET Core 10 generates OpenAPI 3.1 by default. Register AddOpenApi() and map MapOpenApi() in an appropriate environment. Typed endpoint results help describe response schemas and status codes accurately.
Compare the candidate specification against the last released specification, not only a moving main branch. Otherwise a destructive change merged earlier can disappear from the diff while remaining undeployed.
Specification checks provide breadth; executable consumer interactions provide specific usage evidence. Neither proves that the implementation conforms to every documented schema. Add runtime schema validation or integration assertions where that assurance is required.
2.5 Creating the Solution and Repository Structure
Keep application and test projects separate:
| Directory | Responsibility |
|---|---|
src/Product.Api | Provider endpoints and catalog access |
src/Order.Service | Order workflow and ProductClient |
src/Shipping.Service | Shipping workflow and its own client |
tests/Order.Contracts | Order-owned consumer tests |
tests/Shipping.Contracts | Shipping-owned consumer tests |
tests/Product.Contracts | Provider verification and hosting fixture |
artifacts/pacts | Generated files, recreated by CI |
Create a net10.0 Web API, two worker services, and xUnit projects. Add references from consumer tests to their own application, and from provider tests to Product API. Add PactNet to all three test projects:
dotnet add tests/Order.Contracts package PactNet --version 5.0.1
dotnet add tests/Shipping.Contracts package PactNet --version 5.0.1
dotnet add tests/Product.Contracts package PactNet --version 5.0.1
dotnet add src/Product.Api package Microsoft.AspNetCore.OpenApi --version '10.0.*'
Use Microsoft.Extensions.Http in worker projects for typed client registration. Resolve the Microsoft package wildcard once, then commit exact versions and lock files. Pin an installed .NET 10 SDK in global.json too.
For local dependencies, start a disposable Broker and PostgreSQL using Compose:
services:
postgres:
image: postgres:16
environment:
POSTGRES_USER: pact
POSTGRES_PASSWORD: local-only
POSTGRES_DB: pact
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pact -d pact"]
interval: 2s
timeout: 2s
retries: 30
broker:
image: pactfoundation/pact-broker:latest
ports: ["127.0.0.1:9292:9292"]
environment:
PACT_BROKER_DATABASE_URL: postgres://pact:local-only@postgres/pact
depends_on:
postgres:
condition: service_healthy
Run docker compose up -d, then run each .NET application with its own local configuration. Consumer tests need neither live application nor Broker; they use Pact’s mock server. The Compose tags above simplify a disposable setup: pin tested image digests before relying on this configuration in CI, add durable database storage, and replace local credentials for shared hosting.
3 Implementing Consumer-Driven Contract Tests with PactNet
A useful consumer contract exercises code that ships. Sending a handcrafted HTTP request from a test proves little if production uses a different client, serializer configuration, or error-handling path.
3.1 Building a Real HTTP Consumer in the Order Service
Order owns its response model. Required properties prevent a missing price from silently becoming zero:
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
public sealed record OrderProduct
{
public required int ProductId { get; init; }
public required decimal Price { get; init; }
public required string Currency { get; init; }
public required string Availability { get; init; }
}
public sealed class ProductClient(HttpClient http)
{
private static readonly JsonSerializerOptions Json =
new(JsonSerializerDefaults.Web)
{
RespectNullableAnnotations = true
};
public async Task<OrderProduct?> GetAsync(
int id, CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(
HttpMethod.Get, $"api/products/{id}");
request.Headers.Accept.ParseAdd("application/json");
using var response = await http.SendAsync(request, cancellationToken);
if (response.StatusCode == HttpStatusCode.NotFound)
return null;
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<OrderProduct>(
Json, cancellationToken)
?? throw new JsonException("Product response was null.");
}
}
required addresses missing properties. Nullable annotation enforcement addresses supported nullability cases; it does not validate positive identifiers, currency codes, or every nested collection constraint. Unknown response properties remain tolerated with these options.
Register the client in the worker’s service collection:
builder.Services.AddHttpClient<ProductClient>(http =>
{
http.BaseAddress = new Uri(
builder.Configuration["ProductApi:BaseUrl"]
?? throw new InvalidOperationException("Product API URL is missing."));
http.Timeout = TimeSpan.FromSeconds(5);
});
Use a base address ending in /. In the order workflow, require an available product, check the expected currency, and calculate product.Price * quantity using decimal arithmetic. Unit-test quantity limits, rounding, and unavailable-product decisions separately. Contract tests establish the input boundary those policies depend on.
3.2 Writing the First Consumer Contract Test
Place this class in Order.Contracts. Set PACT_DIR to an absolute output directory when invoking the tests:
using System.Net;
using PactNet;
using PactNet.Matchers;
using Xunit;
public sealed class ProductContractTests
{
private static IPactBuilderV4 NewPact() =>
Pact.V4("Order Service", "Product API", new PactConfig
{
PactDir = Environment.GetEnvironmentVariable("PACT_DIR")
?? Path.Combine(AppContext.BaseDirectory, "pacts")
}).WithHttpInteractions();
[Fact]
public async Task Reads_product_for_order_pricing()
{
var pact = NewPact();
pact.UponReceiving("a product lookup for order pricing")
.Given("product 101 exists")
.WithRequest(HttpMethod.Get, "/api/products/101")
.WithHeader("Accept", "application/json")
.WillRespond()
.WithStatus(HttpStatusCode.OK)
.WithHeader("Content-Type", "application/json; charset=utf-8")
.WithJsonBody(new
{
productId = 101,
price = Match.Number(125.50m),
currency = "USD",
availability = "inStock"
});
await pact.VerifyAsync(async context =>
{
using var http = new HttpClient
{ BaseAddress = context.MockServerUri };
var product = await new ProductClient(http).GetAsync(101);
Assert.NotNull(product);
Assert.Equal(101, product.ProductId);
Assert.Equal(125.50m, product.Price);
Assert.Equal("USD", product.Currency);
Assert.Equal("inStock", product.Availability);
});
}
}
The numeric matcher allows provider prices other than the example value. The assertion checks that the client reads the example Pact served. These are separate checks: consumer assertions validate client behavior; provider matching rules define provider obligations.
Match.Number accepts integral and fractional JSON numbers, appropriate for prices such as 125 and 125.50. A decimal-specific matcher would unnecessarily constrain their lexical representation.
3.3 Making Contracts Flexible and Reliable
Flexibility should follow an actual consumer requirement. If Shipping handles several availability values, a bounded matcher can express that domain:
availability = Match.Regex("inStock", "^(inStock|backOrder|unavailable)$")
That matcher belongs in Shipping’s body definition, not as an unrelated global rule. Order’s existing interaction deliberately exercises the available-product path with an exact value. Add separate interactions when unavailable products change its behavior.
For a collection endpoint, Match.MinType(new { productId = Match.Integer(101) }, 1) expresses at least one structurally valid element. Test empty results separately if the client handles them. A type matcher on a string does not make its property optional. To support an optional field, demonstrate absence and presence in separate interactions when the distinction matters; test explicit null separately too.
Add this second test to the Order test class:
[Fact]
public async Task Returns_null_for_unknown_product()
{
var pact = NewPact();
pact.UponReceiving("an order lookup for a missing product")
.Given("product 101 is missing")
.WithRequest(HttpMethod.Get, "/api/products/101")
.WithHeader("Accept", "application/json")
.WillRespond()
.WithStatus(HttpStatusCode.NotFound);
await pact.VerifyAsync(async context =>
{
using var http = new HttpClient { BaseAddress = context.MockServerUri };
Assert.Null(await new ProductClient(http).GetAsync(101));
});
}
Only constrain an error body if the consumer reads it. For validation failures, exercise a request the real client can send, such as identifier zero, and assert the resulting HttpRequestException has status 400. If production parses Problem Details, contract-test that parsing path and the specific fields it uses. Do not invent a 422 response merely because another API uses it.
3.4 Adding a Second Consumer with Different Expectations
Shipping owns a smaller model:
public sealed record ShippingProduct
{
public required int ProductId { get; init; }
public required string Availability { get; init; }
}
Implement ShippingProductClient with the same HTTP and 404 handling pattern as ProductClient, deserializing ShippingProduct instead. Its tests must invoke that client, not Order’s implementation.
Create its Pact builder with Pact.V4("Shipping Service", "Product API", config).WithHttpInteractions(). The success interaction uses the same route and provider state, but its body contains only:
new
{
productId = 101,
availability = Match.Regex(
"inStock", "^(inStock|backOrder|unavailable)$")
}
Call ShippingProductClient.GetAsync(101) inside VerifyAsync and assert those two consumed properties. This produces an independent consumer-provider contract. Product API can return additional response fields during verification, so changing pricing alone should not invalidate Shipping’s expectations.
3.5 Generating, Inspecting, and Publishing Pact Files
Run consumer tests from the repository root with a clean artifact directory:
export PACT_DIR="$PWD/artifacts/pacts"
rm -rf "$PACT_DIR"
mkdir -p "$PACT_DIR"
dotnet test tests/Order.Contracts
dotnet test tests/Shipping.Contracts
The destructive cleanup applies only to generated artifacts. Keep each consumer-provider pair’s tests together and avoid parallel processes writing the same Pact file. If jobs run independently, use separate directories and publish their outputs independently.
Inspect generated JSON for the participant names, providerStates, expected requests, matching rules, and metadata.pactSpecification.version identifying v4. Never hand-edit generated contracts to make provider verification pass.
Install a pinned Pact CLI distribution exposing the traditional pact-broker command. With Broker URL and authentication configured through PACT_BROKER_BASE_URL, PACT_BROKER_USERNAME, and PACT_BROKER_PASSWORD, publish:
pact-broker publish "$PACT_DIR" \
--consumer-app-version "$GIT_SHA" \
--branch "$GIT_BRANCH"
Use the commit of the actual consumer artifact, not a timestamp or mutable latest label. In separate repositories, each consumer has its own SHA. The Broker relationship view should now show two consumers pointing to Product API. At this stage, those relationships express expectations; they do not yet contain provider compatibility evidence.
4 Verifying Contracts Against the ASP.NET Core Provider
Provider verification should exercise the application’s actual HTTP pipeline and serialization. Its dependencies can be isolated, but replacing the endpoint with a test-only response defeats the purpose.
4.1 Implementing the Product API Provider
The following compact Program.cs exposes a reusable application factory. Domain storage and the response DTO remain provider-owned:
using System.Collections.Concurrent;
using Microsoft.AspNetCore.Http.HttpResults;
var app = ProductApplication.Build(args);
app.Run();
public static class ProductApplication
{
public static WebApplication Build(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddSingleton<ProductStore>();
builder.Services.AddOpenApi();
var app = builder.Build();
app.MapGet("/api/products/{id:int}",
Results<Ok<ProductResponse>, NotFound, ValidationProblem>
(int id, ProductStore store) =>
{
if (id <= 0)
return TypedResults.ValidationProblem(
new Dictionary<string, string[]>
{ ["id"] = ["Must be positive."] });
if (!store.Items.TryGetValue(id, out var product))
return TypedResults.NotFound();
return TypedResults.Ok(new ProductResponse(
product.Id, product.Name, product.UnitPrice,
product.Currency, product.Availability));
});
if (app.Environment.IsDevelopment()
|| app.Environment.IsEnvironment("ContractTest"))
app.MapOpenApi();
if (app.Environment.IsEnvironment("ContractTest"))
{
app.MapPost("/provider-states", (
ProviderState request, ProductStore store) =>
{
store.Items.Clear();
switch (request.State)
{
case "product 101 exists":
store.Seed();
break;
case "product 101 is missing":
break;
default:
return Results.BadRequest("Unknown provider state.");
}
return Results.Ok();
});
}
return app;
}
}
public sealed record CatalogProduct(
int Id, string Name, decimal UnitPrice,
string Currency, string Availability);
public sealed record ProductResponse(
int ProductId, string Name, decimal Price,
string Currency, string Availability);
public sealed record ProviderState(string State);
public sealed class ProductStore
{
public ConcurrentDictionary<int, CatalogProduct> Items { get; } = new();
public ProductStore() => Seed();
public void Seed() => Items[101] = new(
101, "Wireless Keyboard", 125.50m, "USD", "inStock");
}
ASP.NET Core’s web JSON defaults serialize the record properties as camel case. The endpoint returns a typed 404 without requiring an error body, and an explicit 400 validation response for nonpositive identifiers.
This store is a tutorial substitute for catalog persistence. In an application using EF Core, retain the real repository and run against an isolated test database when database mappings affect the response. Consumer tests should not reference these provider types.
4.2 Configuring PactNet Provider Verification
PactNet’s native verifier needs a reachable TCP endpoint. The default in-memory TestServer used by WebApplicationFactory is unsuitable for that connection. Start Kestrel directly; choosing port zero lets the operating system allocate an available port.
In Product.Contracts, reference Product API and add this xUnit v2 fixture:
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using PactNet.Verifier;
using Xunit;
public sealed class ProductFixture : IAsyncLifetime
{
private WebApplication app = null!;
public Uri BaseUri { get; private set; } = null!;
public async Task InitializeAsync()
{
app = ProductApplication.Build(
["--environment", "ContractTest"]);
app.Urls.Add("http://127.0.0.1:0");
await app.StartAsync();
var server = app.Services.GetRequiredService<IServer>();
var address = server.Features
.Get<IServerAddressesFeature>()!.Addresses.Single();
BaseUri = new Uri(address);
}
public async Task DisposeAsync()
{
await app.StopAsync();
await app.DisposeAsync();
}
}
public sealed class ProductVerification(ProductFixture fixture)
: IClassFixture<ProductFixture>
{
[Fact]
public void Satisfies_local_contracts()
{
var directory = Environment.GetEnvironmentVariable("PACT_DIR")
?? throw new InvalidOperationException("PACT_DIR is required.");
var files = Directory.GetFiles(directory, "*.json");
Assert.NotEmpty(files);
foreach (var file in files)
{
using var verifier = new PactVerifier("Product API");
verifier.WithHttpEndpoint(fixture.BaseUri)
.WithFileSource(new FileInfo(file))
.WithProviderStateUrl(new Uri(
fixture.BaseUri, "/provider-states"))
.Verify();
}
}
}
Run this test after generating both consumer artifacts. Keep this directory specific to Product API; do not indiscriminately verify unrelated providers’ files.
For Broker-backed CI, replace the local file loop with a single verifier using this source configuration:
verifier.WithHttpEndpoint(fixture.BaseUri)
.WithPactBrokerSource(new Uri(brokerUrl), options =>
{
options.BasicAuthentication(brokerUser, brokerPassword)
.ConsumerVersionSelectors(
new ConsumerVersionSelector { MainBranch = true },
new ConsumerVersionSelector { DeployedOrReleased = true })
.ProviderBranch(providerBranch)
.EnablePending()
.IncludeWipPactsSince(new DateTime(2026, 10, 1))
.PublishResults(providerSha, publish =>
publish.ProviderBranch(providerBranch));
})
.WithProviderStateUrl(new Uri(fixture.BaseUri, "/provider-states"))
.Verify();
Read these configuration values from environment variables, failing immediately when required values are missing. Use PACT_SOURCE=broker to select this path in the test method; retain the file path for local debugging. The provider SHA must identify the exact build being exercised. Configure each participant’s main branch in the Broker before relying on MainBranch selectors.
4.3 Managing Provider States and Repeatable Test Data
Before verification requests, Pact calls the configured provider-state endpoint with the state name and, when configured, parameters. The example accepts the state name and ignores additional JSON members. The endpoint clears the store and recreates the required record, making each interaction independent.
For parameterized states, deserialize the params object into explicitly validated values. Seed through provider-owned infrastructure rather than letting consumer teams write arbitrary SQL. A state such as customer has active subscription should express a domain precondition, not dictate table layouts.
Unknown states must fail visibly. Silently returning success leaves the verifier testing an accidental environment. Setup should be idempotent, and cleanup should not depend on the preceding interaction completing successfully.
The reset strategy shown here requires sequential verification against that application instance. Concurrent verifiers need separate application processes and databases, or isolated data partitions. A thread-safe dictionary does not make clearing shared state semantically safe.
Never expose this endpoint in production or run it against production data. Gate its registration by environment and ensure the contract-test deployment cannot obtain production credentials.
4.4 Introducing an Intentional Breaking API Change
Rename Price to UnitPrice on ProductResponse, preserving its constructor position. The endpoint still builds, but the serialized response now contains unitPrice instead of price.
Provider verification for Order should report a missing value at the response path $.price. The exact diagnostic wording depends on the native verifier version. Shipping’s interaction should pass because it requires neither pricing property.
That difference is valuable evidence. It identifies a specific dependency rather than producing an undifferentiated API failure. It also demonstrates why the provider’s own tests can remain green: its new DTO is internally consistent.
Preserve verifier diagnostics as CI artifacts. With xUnit v2, route Pact output through a custom PactNet.Infrastructure.Outputters.IOutput adapter implementing WriteLine(string) and forwarding to ITestOutputHelper; configure it through PactVerifierConfig.Outputters. This keeps mismatch details attached to the failing test rather than relying on uncaptured console output.
4.5 Fixing the Breaking Change Without Coordinated Deployments
Expand the response temporarily:
public sealed record ProductResponse(
int ProductId, string Name, decimal Price,
decimal UnitPrice, string Currency, string Availability);
Update the mapping to pass product.UnitPrice into both price positions. Deriving both values from the same domain property prevents accidental divergence. Verify the old Order contract, Shipping’s contract, and a new Order contract expecting unitPrice.
Deploy the expanded provider first. Then deploy the updated Order Service after its environment gate passes. Remove price only when no supported, deployed, or required rollback consumer still depends on it.
A deprecation date alone is insufficient. Require current deployment evidence, verified replacement contracts, an agreed rollback window, and an explicit policy for unregistered consumers. For external clients, telemetry and published support commitments may require retaining the old representation much longer.
5 Automating Contract Verification and Deployment Safety with CI/CD
Contracts become a release control only when publication, verification, and deployment records are tied to immutable builds. A green test without version metadata cannot reliably answer whether the next deployment is compatible.
5.1 Designing the Consumer and Provider CI/CD Workflows
The consumer pipeline builds the application, runs its tests, generates Pacts, and publishes them. The provider pipeline builds the candidate, selects relevant contracts, verifies them, and publishes successful and failed results.
This GitHub Actions skeleton uses the monorepo layout. Configure Broker secrets, its URL, and an approved PACT_BROKER_CLIENT_VERSION repository variable first:
name: contracts
on: push
permissions:
contents: read
env:
PACT_BROKER_BASE_URL: ${{ vars.PACT_BROKER_BASE_URL }}
PACT_BROKER_USERNAME: ${{ secrets.PACT_BROKER_USERNAME }}
PACT_BROKER_PASSWORD: ${{ secrets.PACT_BROKER_PASSWORD }}
jobs:
consumers:
runs-on: ubuntu-24.04
strategy:
matrix:
consumer: [Order, Shipping]
env:
PACT_DIR: ${{ github.workspace }}/artifacts/pacts/${{ matrix.consumer }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
- name: Install approved Broker client
env:
CLI_VERSION: ${{ vars.PACT_BROKER_CLIENT_VERSION }}
run: gem install pact_broker-client --version "$CLI_VERSION" --no-document
- run: dotnet build src/${{ matrix.consumer }}.Service --configuration Release
- run: dotnet test tests/${{ matrix.consumer }}.Contracts --configuration Release
- name: Publish generated contracts
env:
VERSION: ${{ github.sha }}
BRANCH: ${{ github.ref_name }}
run: >-
pact-broker publish "$PACT_DIR"
--consumer-app-version "$VERSION" --branch "$BRANCH"
provider:
needs: consumers
runs-on: ubuntu-24.04
env:
PACT_SOURCE: broker
PROVIDER_SHA: ${{ github.sha }}
PROVIDER_BRANCH: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- run: dotnet build src/Product.Api --configuration Release
- run: dotnet test tests/Product.Contracts --configuration Release
The provider test reads the PACT_BROKER_* and PROVIDER_* variables when selecting the Broker-backed path from section 4.2. Configure a required result-publication failure as a failed CI run too; an unavailable Broker must not silently discard evidence.
Use dedicated repositories by moving each job into its owning service pipeline and replacing the dependency with Broker webhooks. In the monorepo, a shared SHA is valid because the applications come from that commit. Build artifacts must still remain traceable to it.
The workflow runs trusted pushes. Run offline consumer tests for untrusted pull requests without Broker credentials; publish only from authorized contexts. Pin action revisions, SDK resolution, dependencies, and the Ruby client version according to repository policy. Upload test logs and generated Pacts for failed-build diagnosis, with retention appropriate to their contents.
5.2 Understanding the Pact Broker Compatibility Matrix
The matrix connects consumer contract content and application versions to provider verification results. It distinguishes a verified pair from a convenient branch label.
| Consumer | Provider | Result | Meaning |
|---|---|---|---|
Order O1, expects price | Product P1 | Pass | Original pair is verified |
| Order O1 | Product P2, rename only | Fail | Candidate breaks the old consumer |
| Shipping S1 | Product P2 | Pass | Shipping does not depend on pricing |
| Order O1 | Product P3, both fields | Pass | Expanded provider preserves old callers |
Order O2, expects unitPrice | Product P3 | Pass | Updated caller has provider support |
These identifiers are readable aliases; use immutable application versions in actual commands. A pass for O2/P3 says nothing about O2/P1. Likewise, verifying the provider’s main branch does not establish that an older production provider supports a newly published consumer.
Repeated identical Pact content can reuse compatibility evidence through the Broker’s content model. Publish each application version anyway so deployment tracking remains accurate. Do not overwrite a consumer version with different contract content.
5.3 Enforcing Deployment Gates with Can-I-Deploy
Create named environments once, then record the real deployed versions of every participating service. Bootstrap an existing estate by publishing contracts from those releases and verifying the deployed provider builds. Until that evidence exists, a gate cannot reconstruct reality.
Immediately before deploying Product API, run:
pact-broker can-i-deploy \
--pacticipant "Product API" --version "$PROVIDER_SHA" \
--to-environment production \
--retry-while-unknown 12 --retry-interval 10
Run the equivalent check for every consumer deployment. An unknown required result is not a pass. Bounded retries allow webhook-triggered verification to finish; they do not override a known incompatibility.
With O1 deployed, P2 is blocked because its rename breaks price. After P3 publishes passing verification results for the relevant production contracts, P3 can pass the gate. Deploy P3, complete rollout and health checks, then record:
pact-broker record-deployment \
--pacticipant "Product API" --version "$PROVIDER_SHA" \
--environment production
Deployment itself belongs between these commands. Never record a deployment merely because verification succeeded. If rollout fails, preserve the record of the version actually serving traffic and reconcile any partial deployment explicitly.
Serialize releases that can invalidate each other’s compatibility assumptions. A gate is a query, not a lock across your estate. Rolling deployments also contain overlapping versions temporarily; preserve compatibility across that overlap. Recording only the final version does not prove that intermediate traffic was safe.
5.4 Handling Multiple Branches, Pending Pacts, and Webhooks
Select consumer main branches together with deployed or supported released versions. Main alone misses older production consumers; production alone misses upcoming requirements. Configure branches as metadata rather than inferring them from version strings.
Pending Pacts let a new, unsupported consumer expectation report a failed verification without necessarily failing the provider’s overall build. The Broker calculates pending status relative to provider branch history. Once supported expectations become established for that branch, regressions should fail normally.
This does not authorize the consumer to deploy. Its environment gate still needs compatible provider evidence. During feature implementation, add a strict check for the exact new contract so pending behavior cannot hide unfinished work.
WIP selection adds qualifying recent pending contracts outside the explicit selectors. Choose a cutoff intentionally and revisit abandoned feature branches. Matching-branch selection can help two teams develop together, but keep main and deployed selectors too.
Use a Broker webhook to trigger provider verification when contract content changes. Verify the exact Pact URL from the event; do not race against a mutable latest-contract URL. Where necessary, verify both the provider’s development build and its production release commit, publishing each result under its own provider version. Authenticate webhook requests and constrain allowed repositories and URLs.
5.5 Combining Pact and OpenAPI Compatibility Checks
Generate the candidate document from the running candidate API:
curl --fail http://127.0.0.1:5010/openapi/v1.json \
--output artifacts/product-candidate.json
oasdiff breaking --fail-on ERR \
artifacts/product-released.json artifacts/product-candidate.json
Start that API in Development with deterministic configuration, wait for readiness, and obtain the baseline from the last released artifact. Keep both documents as build outputs. A successful diff only evaluates the specification; inaccurate endpoint metadata still needs correction.
Block pull requests on unit and integration failures, regressions in established contracts, and unapproved specification breaks. A newly published pending expectation can remain nonblocking for unrelated provider work. Production deployment must additionally pass the environment-specific gate and rollout checks.
Avoid a blanket specification exception because no known Pact fails. Unknown consumers may depend on the removed surface. Exceptions require an explicit compatibility decision and retirement plan, not merely an absence of registered tests.
6 Advanced Contract Testing Patterns and Enterprise Design Decisions
The basic workflow scales only when teams understand what constitutes a supported contract, how long compatibility must last, and which risks remain outside contract verification.
6.1 API Evolution and Backward Compatibility Strategies
Prefer additive changes when the consumer population upgrades at different speeds. Introduce the new representation, prove that it works, migrate consumers, then retire the old one. Compatibility work belongs in the design of a change, not in a release-day exception.
Request and response evolution have different failure modes:
| Change | Risk to existing consumers | Practical approach |
|---|---|---|
| New mandatory request field | Old callers omit it | Start optional with documented behavior |
| Removed response property | Old callers still read it | Retain an alias during migration |
| Newly nullable response value | Old code requires a value | Add explicit null-handling scenarios |
| New response enum value | Exhaustive consumer logic fails | Agree fallback behavior or version the domain |
200 becomes 202 | Caller assumes completion | Treat asynchronous acceptance as a behavior change |
| Different numeric units | Parsing passes, meaning changes | Use explicit units and semantic tests |
Do not confuse a field being optional in C# with being optional on the wire. JSON configuration, required-member handling, and generated client behavior determine the actual boundary. Test absence, null, and valid values when they lead to different decisions.
Version the API when representations or semantics cannot coexist cleanly, or when a public support policy requires distinct contracts. A /v2 route creates another supported surface; it does not migrate callers automatically. Each version still needs ownership, deployment evidence, and retirement criteria.
A short-lived alias is usually simpler for a property rename. A fundamentally different pricing model, authorization boundary, or workflow may justify a new resource or API version instead.
6.2 Contract Testing for Asynchronous Messaging
For messages, the consumer is the application reading the event and the provider is the producer. There is no HTTP response to verify. The agreement concerns message contents and any metadata the consumer requires.
Suppose Product API publishes an availability event used by Shipping:
{
"eventId": "54a063d7-7e68-4dd7-9078-4cb43c46e21b",
"eventType": "ProductAvailabilityChanged",
"productId": 101,
"availability": "unavailable",
"occurredAt": "2026-11-04T10:00:00Z"
}
Shipping’s contract should exercise its real message-processing adapter with the expected contents. It might require product identification, availability, event identity for deduplication, and a content-type metadata value. Avoid asserting an exact timestamp unless time itself determines behavior.
PactNet 5 supports v4 message interactions through WithMessageInteractions(), ExpectsToReceive(...), and WithJsonContent(...). VerifyAsync<T> can feed the generated example into the consumer handler. When production performs additional envelope extraction or custom byte deserialization, exercise that adapter too rather than bypassing it with a preconstructed object.
On the producer side, configure PactVerifier.WithMessages(...) with generators calling the real event construction code, and use production-equivalent serialization settings. A hand-authored test event that drifts from the actual publisher offers little protection. V4 can combine HTTP and asynchronous message interactions between the same participants; avoid copying older guidance that requires separate identities solely because the transports differ.
RabbitMQ exchanges, routing keys, queue bindings, and acknowledgments still require transport integration tests. Azure Service Bus sessions, lock renewal, dead-lettering, and delivery retries do too. Message contracts do not prove delivery or ordering.
For event evolution, adding unused fields is often safe. Renaming productId, changing an identifier’s type, or removing metadata used for dispatch is not. Keep schema compatibility checks alongside Pact where many consumers share the event. Preserve idempotency and replay tests: historical messages can outlive the producer deployment that created them.
6.3 Common Anti-Patterns and Testing Limitations
Over-mocking is the first failure mode. A test that mocks ProductClient and asserts a DTO never checks HTTP construction or JSON handling. A provider verification that substitutes a fake endpoint never checks production serialization.
Over-specification creates a different problem. Snapshotting every field couples consumers to information they do not use. Too many exact values turn harmless data variation into build failures. Excessively permissive matching is equally damaging: accepting any string for a field used in a bounded business decision conceals incompatible values.
Other warning signs include states that depend on yesterday’s seed data, contracts published from stale build directories, permanent continue-on-error settings, and consumers deleted from code but still marked as deployed. Diagnose flaky setup rather than adding unbounded retries.
Passing contracts establish compatibility for represented interactions under represented states. They do not prove security, complete business correctness, capacity, network resilience, or compatibility with unknown callers. Maintain separate tests and operational controls for those properties.
6.4 Scaling Consumer-Driven Contracts Across Engineering Teams
Consumers own their requirements and tests; providers own state setup, verification, and compatible implementation. Both teams review changes to an interaction. A platform team can supply shared CI templates and Broker operations without becoming the approval bottleneck for every endpoint.
Self-hosting the Broker gives infrastructure control but adds database backups, upgrades, authentication, monitoring, and availability responsibilities. A managed option such as PactFlow shifts some operational work to a vendor; evaluate cost, access controls, residency needs, and integration features against your requirements.
Keep active deployment and supported-release evidence when applying retention policies. Deleting old versions indiscriminately can erase rollback evidence. Use Broker-supported cleanup mechanisms and rehearse restoration.
Control execution costs through focused interactions, selected active contracts, reusable provider startup, and isolated parallel jobs. Do not weaken matchers to reduce failures. Start adoption with one costly service boundary, establish ownership and a working release gate, then expand based on production risk.
7 Production Readiness, Implementation Checklist, and Key Takeaways
The final measure of this approach is whether an engineer can make an API change, identify affected consumers, and release a compatible build without arranging a synchronized deployment across teams.
7.1 End-to-End Walkthrough: From API Change to Safe Deployment
Begin with Product P1, Order O1, and Shipping S1 deployed and recorded. O1 requires price; S1 requires identification and availability. Their contracts describe those dependencies independently.
The rename-only candidate P2 fails O1’s provider verification. Its deployment gate also fails against production. Shipping remains compatible, which narrows the investigation to the pricing boundary rather than the entire fulfillment flow.
P3 returns both price and unitPrice, derived from one domain value. Verify P3 against O1, S1, and the proposed O2 contract. After P3 passes the production gate and completes rollout, record its deployment. O2 can then deploy independently using unitPrice.
Do not remove price immediately. O1 may remain active in another environment, and rolling back Order to O1 must still work. Once supported consumers and rollback candidates no longer require it, create a removal candidate, rerun compatibility checks, and release through the same process.
The consumer demonstrates its assumptions, the provider proves support, and CI preserves evidence while checking deployment decisions. None of those responsibilities can replace the other two.
7.2 Production-Ready Contract Testing Checklist
Use this checklist before treating contracts as an enforced release control:
- Consumer tests call shipping HTTP or message adapters and cover the fields, status codes, and negative paths actually used. Missing and nullable values receive deliberate treatment.
- Provider verification runs the real application pipeline on reachable TCP endpoints, with deterministic states and isolated dependencies. State-management endpoints are absent from production.
- Publications use immutable application versions, stable participant names, correct branch metadata, and clean generated artifacts. Verification reports include failures as well as successes.
- Deployment jobs query the target environment immediately before release and record reality only after rollout succeeds. Unknown compatibility results fail closed, with bounded waiting when verification is still running.
- Rollback builds remain available and compatible. Shared environments, long-lived tenants, and supported releases are modeled explicitly rather than hidden behind a single inaccurate production record.
- Broker access uses TLS and least-privilege credentials. Secrets stay in CI secret stores, logs avoid tokens, webhook callers are authenticated, and contract examples contain synthetic data.
- Broker and database backups, recovery procedures, retention rules, and outage handling have owners. An emergency override is audited and temporary, never a permanent bypass embedded in YAML.
Authentication deserves separate attention. Use a controlled test identity or locally issued test token when verifying protected endpoints. Preserve the relevant authentication middleware where practical, while testing identity-provider integration separately. Never publish reusable production tokens inside Pact request headers.
7.3 Choosing the Right Contract Testing Strategy
Consumer-driven contracts provide the greatest value when known teams deploy independently and compatibility defects are expensive to discover late. They are especially useful where consumers use different subsets of a broad response, as Order and Shipping do here.
For a public API with largely unknown callers, OpenAPI compatibility policy and SDK validation may provide broader protection. Registered Pacts can supplement that policy but cannot stand in for the unobserved consumer population.
For a single application deployed atomically, ordinary integration tests may cover the boundary adequately. Introducing a Broker adds little value if no independent version combinations exist. For infrastructure-heavy failures, retain integration tests against real databases, identity systems, and messaging transports.
Choose based on release independence, visibility of consumers, and the failure being prevented. Use end-to-end tests for critical journeys whose correctness depends on orchestration, not just compatible messages.
7.4 Final Architectural Lessons
Independent deployment requires sustained backward compatibility. Contract verification makes part of that requirement executable and identifies unsupported changes before they reach the deployment step.
Start with Product’s equivalent in your own estate: one frequently changed API with a known consumer and a history of integration risk. Exercise the real client, establish repeatable provider verification, publish versioned evidence, and connect it to environment-aware release gates. Once that path works, add the next consumer.
The result is a practical engineering capability: teams can evolve their applications at different speeds while making compatibility decisions from explicit, reviewable evidence.



