Software Architecture · Distributed Architecture
Independent Deployability and Cross-Service Contracts
What must be true for two services to be released on separate schedules, how wire formats absorb change, and why a passing contract test does not prove compatibility.
A service boundary is only real if the two sides can be released separately. Organisations discover this late: they split a monolith into services, keep the release train, and find that every deployment still requires a documented order and a coordinated window. The architecture has changed shape without changing its coupling.
The test is concrete. Can version N of service A run against version N+1 of service B, and version N+1 of A run against version N of B, at the same time, with no configuration change and no ordering requirement? If both combinations work, the boundary supports independent deployment. If either one fails, the two services are one deployable unit with a network hop inside it.
Why the mixed-version window is unavoidable
Rolling deployments, canaries, and blue-green switches all produce a period in which two versions of a service are live at once. Even a single-instance cutover has a window in which the new caller runs before the new provider, or the reverse, because the two services are separate processes with separate release times.
That window is not an operational detail to be minimised; it is the normal state during every change. Designing for compatibility means designing for the mixed-version case as the default, and treating the all-new state as a coincidence.
Two compatibility directions matter. Backward compatibility is a new provider serving old callers. Forward compatibility is an old provider serving new callers, which is harder because the old code has never heard of the new fields and must not misbehave when it receives them. A provider that ignores unknown fields is forward compatible; one that rejects a payload with an unexpected property is not.
What the wire format guarantees
The encoding decides how much of the problem is mechanical.
Protocol Buffers assigns each field a number that is the field’s identity on the wire. Numbers 1 through 15 occupy one byte of tag; 16 through 2047 occupy two; the maximum legal field number is 536,870,911, with the range 19000 to 19999 reserved for the implementation. Unknown fields are preserved and re-emitted by most implementations, so an old service that receives a new field passes it along rather than dropping it. A field that is removed must have its number and name reserved so that a later change cannot reuse them:
message Order {
reserved 4, 7 to 9;
reserved "legacy_total", "currency_code";
string order_id = 1;
int64 total_cents = 2;
string status = 3;
string customer_id = 10; // added later, never reuses a retired number
}
JSON is far more forgiving at the syntax level and far less protective at the semantic level. A reader that ignores unknown properties survives added fields, which covers the common case. Nothing in JSON prevents a field from changing type, and a client that receives a string where it expected a number fails at parse time or, worse, coerces it and produces a plausible wrong value. JSON also has no field-number reservation, so a removed key can be reintroduced later with a different meaning and there is no mechanism to detect the collision.
Both formats share a limit that no encoding can fix: they describe structure, not meaning. A field whose type is unchanged but whose unit changed from cents to dollars is wire compatible and semantically broken. An enum that gained a value is wire compatible with a consumer whose switch statement treats unknown values as an error. Compatibility has to be reasoned about at the level of the contract, with the encoding only removing the mechanical obstacles.
Expand, contract, and the three-deploy rule
Because old and new must coexist, a change that is not purely additive needs more than one release. The pattern is usually called expand and contract, or parallel change.
The expansion adds the new field, endpoint, or table column while the old one still works, and the writers populate both. Nothing reads the new form yet, so the release is safe against every existing consumer. The migration moves readers to the new form, one consumer at a time, with the old form still populated. The contraction removes the old form once no reader depends on it.
The minimum is three deploys, and each one has a precondition that must be verified rather than assumed: that the previous step reached every instance, that no consumer was missed, and that the removal is actually safe. Removing a field that one forgotten batch job reads produces an error in a system whose owner has moved on, and the error appears at the next monthly run.
Databases make this harder than APIs because the schema and the code are separate artefacts with their own release timing. A column that must become NOT NULL cannot be added in one step when two versions of the code are writing rows: the old version does not populate it. The workable sequence is to add the column as nullable, backfill, have the new code write both, add the constraint once no old writer remains, and drop the old column later. Each of those is a separate deploy with a verification step, and skipping one produces a migration that locks a table while it fails.
Deprecation needs a channel. RFC 8594 defines the Sunset HTTP header field, which announces when a resource will stop being available, and it gives clients a machine-readable date rather than a line in a changelog. A header is not a policy, but it moves the announcement into the protocol where monitoring can see it.
Contract testing and where it stops
Consumer-driven contract testing records each consumer’s expectations of a provider and verifies the provider against them in continuous integration. The value is early detection: a provider change that breaks a recorded interaction fails before deployment, and the failure names the consumer that would have broken.
The blind spots are worth stating because they are easy to mistake for coverage.
A contract test covers the interactions that were recorded. A consumer that calls an endpoint only in an error path, or that reads a field only when a flag is set, may have no recorded interaction for that path, and the test suite passes while the behaviour breaks.
The verification runs in the provider’s build against a stub, or against the provider’s real implementation with test data. Neither proves the semantic contract, because the assertion is whatever the consumer wrote down. A consumer that asserts the presence of a field without asserting its unit will pass after the unit changes.
And the tests decay. A consumer whose tests are not run on every provider change, or whose contract file is not regenerated when its own usage changes, produces a suite that passes while describing an older version of the client. The maintenance burden is real, which is why contract testing works best where a small number of consumers have a strong reason to care.
Coupled releases in disguise
Several arrangements look like independent services and behave like a single release.
A shared client library that changes behaviour is the most common. If the library’s new version adds a retry, changes serialisation, or requires a provider feature, then upgrading it in one service is a coordinated change with the provider, and the coordination is invisible because the dependency is a package rather than an API.
A shared database migration is the second. Two services reading the same table cannot have their schema changed at different times, so the migration forces a joint release no matter how separate the deployments appear. The schema is the boundary in that case, which is the reasoning in Drawing Service Boundaries Around Consistency Requirements.
Feature flags that must be enabled in a particular order are the third. The code is deployed independently, but the behaviour is not: flag A in the caller must be on before flag B in the provider, and the runbook that describes the order is a coupling that no architecture diagram shows.
The remedy for all three is the same test as before, applied at the level of behaviour rather than code: pick any two adjacent versions and ask whether they work in both orders. Where the answer is no, the dependency is a release constraint, and it belongs in the design rather than in a deployment checklist. Temporal Coupling Between Services covers the runtime version of the same problem, where the coupling is availability rather than version.
References
- Protocol Buffers Language Guide (proto3) — Google
- RFC 8594: The Sunset HTTP Header Field — IETF
- Building Microservices — Sam Newman, O'Reilly Media, 2015