Contract Testing (catching breaking changes before they reach consumers)
Benzene services talk to each other by topic + payload, and consumers often use a generated typed client. This cookbook shows how a provider publishes its live contract hash, how a consumer detects when that contract has drifted from what its client was built against, and how to gate a deploy on black-box wire conformance.
Problem statement
A service evolves its message contract (a handler's request/response shape, or the set of topics it answers). Some changes are safe (adding an optional response field); some break consumers (removing a response field, adding a required request field). You want to know before the change reaches a consumer, not from a production incident.
The TypeScript port gives you two complementary mechanisms:
- Runtime contract-drift detection — the provider publishes a hash of its current contract; a consumer compares it against the hash its client was generated with and reports drift on a diagnostic topic.
- A deploy-time black-box conformance probe —
CloudServiceProbehits a running service's/benzene/*surfaces and reports a tri-state verdict against the Cloud Service Profile (R1–R8).
Port note. The .NET original of this cookbook also ships a build-time compatibility gate (
SchemaCompatibility.EnsureBackwardCompatible, fromBenzene.Schema.OpenApi.Compatibility) that throws on breaking changes in a unit test. That gate is not ported yet —@benzene/schema-openapiports theEventServiceDocumentbuilder but not theCompatibilitycomparer. Until it lands, use the runtime drift check plus the black-box probe below, and — for a lightweight CI gate — snapshot the service's/benzene/specdocument and diff it (see A lightweight CI gate).
Prerequisites
- Node.js 22+ and a Benzene service — see Getting Started.
- Familiarity with Health Checks — the drift check is an
IHealthCheck.
Mechanism 1 — runtime contract-drift check
The provider publishes a hash of its live contract as a schema health check; a consumer's client fetches
that health response and compares the hash to the one it was generated against. A mismatch means the
provider's contract has moved.
Provider — publish the live contract hash
addSchemaHealthCheck (@benzene/health-checks-schema) registers a check that hashes every registered
handler's topic + request/response schema and publishes it under the schema health check. Register it on
the general healthcheck topic via useHealthCheck:
npm install @benzene/health-checks @benzene/health-checks-schema
// OrderServiceStartUp.ts (provider)
import { IBenzeneServiceContainer } from '@benzene/abstractions';
import { IBenzeneApplicationBuilder } from '@benzene/abstractions-middleware';
import { addBenzene, useMessageHandlers } from '@benzene/core-message-handlers';
import { useAwsLambda } from '@benzene/aws-lambda-core';
import { useApiGateway } from '@benzene/aws-lambda-api-gateway';
import { useHealthCheck } from '@benzene/health-checks';
import { addSchemaHealthCheck } from '@benzene/health-checks-schema';
import { BenzeneStartUp } from '@benzene/testing';
import { CreateOrderHandler } from './handlers.js';
export class OrderServiceStartUp implements BenzeneStartUp {
configureServices(services: IBenzeneServiceContainer): void {
addBenzene(services);
}
configure(app: IBenzeneApplicationBuilder): void {
useAwsLambda(app, (aws) =>
useApiGateway(aws, (api) => {
useMessageHandlers(api, CreateOrderHandler);
useHealthCheck(api, 'healthcheck', (health) => {
addSchemaHealthCheck(health); // publishes the live contract hash under "schema"
});
}),
);
}
}
The hash
@benzene/health-checks-schemapublishes is stable across every TypeScript component that hashes a contract (the mesh descriptor, the/benzene/specendpoint, and this check), which is exactly what the drift loop needs. Its value differs from the .NET port's — the two serialize the spec document differently — so this loop is TypeScript-provider ↔ TypeScript-consumer; a cross-language drift check is not something the current hashes support.
Consumer — compare the provider's hash with the client's
A generated client bakes in the hash it was generated against. The consumer side of the loop is
ClientHealthCheckProcessor.process(providerResponse, clientHash), which reads the provider's schema
hash, compares it, and annotates the response with a ClientHashMatch verdict. Wrap that in an
IHasHealthCheck so the drift check can drive it:
npm install @benzene/clients-health-checks
// OrderServiceClient.ts (consumer)
import { IBenzeneResultOf, ServiceToken, serviceToken } from '@benzene/abstractions';
import { BenzeneResult } from '@benzene/results';
import { IBenzeneMessageSender } from '@benzene/clients';
import { HealthCheckResponse } from '@benzene/health-checks-core';
import { ClientHealthCheckProcessor, IHasHealthCheck } from '@benzene/clients-health-checks';
export const IOrderServiceClient: ServiceToken<IHasHealthCheck> =
serviceToken<IHasHealthCheck>('IOrderServiceClient');
export class OrderServiceClient implements IHasHealthCheck {
// The contract hash this client was generated against, baked in at codegen time.
readonly hashCode = 'e3b0c44298fc1c14';
static readonly inject = [IBenzeneMessageSender] as const;
constructor(private readonly sender: IBenzeneMessageSender) {}
async healthCheckAsync(): Promise<IBenzeneResultOf<HealthCheckResponse>> {
// Call the provider's healthcheck topic (over whatever transport the client is bound to).
const result = await this.sender.sendAsync<unknown, HealthCheckResponse>('healthcheck', {});
if (!result.isSuccessful || result.payload === undefined) {
return result;
}
// Compare the provider's live hash with ours and annotate the drift verdict onto the response.
const annotated = ClientHealthCheckProcessor.process(result.payload, this.hashCode);
return BenzeneResult.ok(new HealthCheckResponse(annotated.isHealthy, annotated.healthChecks));
}
}
Register the drift check on its own topic
ClientHealthCheck (registered by addContractCheck) folds that annotated response into one verdict:
reachable + matching → ok, reachable + drifted → warning, unreachable → failed.
// ConsumerStartUp.ts
import { useHealthCheck } from '@benzene/health-checks';
import { addContractCheck } from '@benzene/clients-health-checks';
import { IOrderServiceClient, OrderServiceClient } from './OrderServiceClient.js';
// in configureServices:
services.addScoped(IOrderServiceClient, OrderServiceClient);
// in configure, inside your transport wiring:
useHealthCheck(api, 'contracts', (health) => {
addContractCheck(health, 'OrderService', IOrderServiceClient);
});
Wire the contract check to monitoring, not to a probe. It calls a downstream service and reports contract drift, so it belongs on a dedicated diagnostic topic (
contractsabove) that your mesh / alerting scrapes — never in a Kubernetes liveness or readiness probe. Coupling it to a probe lets a struggling dependency (or a compatible-but-changed contract) restart or de-route pods that are themselves healthy, and drift is a versioning signal, not a serve-traffic one. See Kubernetes Health Checks → contract-drift checks belong in neither probe.
Port note. .NET ships a dedicated probe-less
contractstopic and aUseContractsCheckmiddleware for exactly this. The port hasClientHealthCheckandaddContractCheck/addContractCheckInstance(@benzene/clients-health-checks) but nouseContractsCheckmiddleware yet — register the contract check on a topic of your own via the generaluseHealthCheckand scrape it directly, as above.
Mechanism 2 — a deploy-time black-box conformance probe
CloudServiceProbe (@benzene/cloud-service-probe) is an external, black-box auditor: it POSTs a small set
of fixed synthetic envelopes at a live service's /benzene/* surfaces and returns a tri-state
assessment of the Cloud Service Profile's R1–R8, built only from what it observed — it never trusts what the
service claims about itself. Run it against a freshly-deployed service (a smoke/gate step in CI) to confirm
it is reachable and wire-conformant before promoting it:
npm install --save-dev @benzene/cloud-service-probe
// conformance.smoke.test.ts
import { describe, expect, it } from 'vitest';
import { CloudServiceProbe } from '@benzene/cloud-service-probe';
describe('deployed OrderService conformance', () => {
it('is wire-conformant to the Cloud Service Profile', async () => {
const report = await CloudServiceProbe.runAsync('https://orders.example.com');
// Ids the probe positively observed as UNMET must be empty.
expect(report.notSatisfied).toEqual([]);
// Ids it structurally cannot observe from outside (e.g. R8, half of R6) — treat as "unknown",
// never as a pass. `runAsync` never throws for an unreachable service; it reports a verdict.
console.log('inconclusive:', report.inconclusive);
});
});
runAsync(baseUrl, options?, fetchFn?, signal?) returns a CloudServiceProbeReport with:
notSatisfied— requirement ids observed as unmet (assert this is empty);inconclusive— ids unobservable from a single external HTTP vantage point (never treat as a pass);isFullyConformant— true only when every requirement was observed as satisfied. Because R8 (and half of R6) are structurally unobservable from outside, this is essentially nevertruefor a real service — that is the honest ceiling of a black-box probe, so gate onnotSatisfiedbeing empty, not onisFullyConformant.
Point it at non-default paths with CloudServiceProbeOptions (invokePath, specPath, healthPath,
sendTraceParentProbe).
A lightweight CI gate
Until the compatibility comparer is ported, the simplest pre-merge stop is to snapshot the service's
contract document and diff it. The /benzene/spec endpoint (served by useSpec from
@benzene/schema-openapi) is the same contract the schema health check hashes:
- In CI, fetch
/benzene/specfrom the service (or render it from the handler definitions) and write it tospec.baseline.json, committed to the repo. - On each PR, regenerate it and fail the build if it differs from the committed baseline — a diff is a contract change that a human must acknowledge (regenerate the baseline deliberately when the change is intended).
This is coarser than .NET's direction-aware EnsureBackwardCompatible (which distinguishes additive from
breaking changes) — it flags any change — but it needs nothing beyond the ported /benzene/spec surface
and stops an unnoticed contract change from merging.
To browse that contract interactively while developing, mount the Spec UI with useSpecUi
(@benzene/spec-ui), which renders the useSpec document in-browser.
Troubleshooting
The consumer's drift check always reports ok, even after a provider change
ClientHealthCheckProcessor can only compare when the provider actually publishes a schema health check —
confirm the provider registered addSchemaHealthCheck and that the consumer is reading the same
healthcheck response. If the provider has no schema check, there is no hash to compare and the check
stays ok (it never invents drift).
The probe reports lots of inconclusive ids
That is expected and honest — a single external HTTP probe genuinely cannot observe R8 (and half of R6).
Gate on report.notSatisfied being empty, and treat inconclusive as "verify by other means", never as a
pass.
CloudServiceProbe.runAsync returns instead of throwing for a dead service
By design — unreachability is reported as a verdict (R1/R3 NotSatisfied), not an exception, so one call
classifies a service whether it's up or down. An externally-supplied AbortSignal still propagates.
Further reading
- Schema Registry (applied) — registering payload schemas centrally so a producer can't ship a breaking wire change silently.
- Schema Registry (reference) — the
@benzene/schema-registry-coresurface. - Health Checks — the
IHealthCheckmodel and theschemacheck. - Kubernetes Health Checks — why the contract check belongs on neither probe.
- Clients — the generated typed clients that carry the contract hash.
- Payload Testing — the
CloudServiceProbesurface in context.