Changing a shared interface is risky when many callers depend on it. A large “update everything at once” patch can work in a small codebase, but it becomes harder to review, deploy, and roll back as dependencies spread across modules or services.
Parallel change is a refactoring technique that keeps old and new interfaces working side by side for a limited period. The migration happens in three stages: expand, migrate, and contract.
Stage one: expand without breaking callers
Suppose a function returns a loosely structured tuple:
lookup_user(id) -> (name, email, active)You want a named result object instead:
lookup_user_record(id) -> UserRecordThe first change adds the new interface while preserving the old one. Ideally both paths delegate to one implementation so behavior does not diverge.
Conceptually:
lookup_user_record(id)
|
+-> canonical implementation
|
lookup_user(id) -> adapt record to old tupleAt the end of the expand stage, no existing caller is required to change.
Keep one source of truth
The temporary compatibility path should be thin.
If old and new implementations each contain business logic, fixes can land in one path but not the other. Prefer an adapter:
def lookup_user(user_id):
record = lookup_user_record(user_id)
return record.name, record.email, record.activeNow the old contract is preserved while the new representation becomes canonical.
Compatibility code is temporary, but it still needs tests because every remaining old caller depends on it.
Stage two: migrate callers incrementally
Move consumers to the new interface in small, independently reviewable changes.
Useful migration units include:
- one module;
- one service;
- one endpoint;
- one package;
- one deployment group.
After each step, the system still supports callers that have not moved yet.
This is especially valuable when producer and consumer deployments are not atomic. The new producer can be released before every consumer updates.
Use tooling to find the remaining surface
Before migration, identify references to the old interface. During migration, keep tracking that count.
A useful completion criterion is not “we think everything moved.” It is “there are no supported callers left, and any intentional compatibility consumers are documented.”
Search results, compiler errors, static analysis, deprecation warnings, runtime telemetry, and API access logs can all help depending on the interface type.
Make the compatibility direction intentional
Adapters can point either way:
old -> newor:
new -> oldPrefer the direction that makes the desired future interface canonical. That keeps new features and fixes centered on the path that will survive.
If the old implementation remains primary until the final day, new code may continue depending on old abstractions and make deletion harder.
Stage three: contract the old surface
Once callers have migrated, remove:
- the old function or endpoint;
- adapters used only for compatibility;
- deprecation shims;
- old tests that no longer describe supported behavior;
- obsolete metrics and documentation.
The contraction step matters. Leaving compatibility paths indefinitely turns a temporary migration technique into permanent complexity.
Treat deletion as part of the original plan, not optional cleanup.
Parallel change across service boundaries
The same technique applies to APIs and events.
Imagine an event field changing from:
{"customer_name":"Ari"}to:
{"customer":{"display_name":"Ari"}}A safe rollout can be:
- producers emit both representations;
- consumers learn to read the new representation;
- telemetry confirms old-field usage has reached zero;
- producers stop emitting the old field.
For read APIs, the server can temporarily return both fields. For write APIs, the server may temporarily accept both input shapes and normalize them internally.
The details differ, but compatibility overlap prevents deployment ordering from becoming a correctness requirement.
Do not keep two writable truths
Parallel change becomes dangerous when both old and new representations can be independently modified.
For example, storing both customer_name and customer.display_name as separate authoritative fields creates synchronization questions.
During migration, choose one canonical representation and derive compatibility output from it whenever possible.
This reduces the state space that tests and operators must reason about.
Define observability before migration
For distributed migrations, add a way to measure old-path usage before you need to remove it.
Useful signals include:
- requests using an old endpoint;
- payloads containing a deprecated field;
- calls to a compatibility function;
- consumers reading an old event version;
- deprecation warnings grouped by caller.
Without usage data, teams often keep old interfaces forever because nobody can prove deletion is safe.
Put an expiration date on temporary code
Compatibility code tends to become invisible once the migration stops causing pain.
Track:
- an owner;
- the target removal milestone;
- the condition for deletion;
- any known callers still blocking removal.
A comment saying “temporary” is weak. A ticket, deadline, or automated warning connected to ownership is stronger.
Common pitfalls
Changing producer and all consumers in one giant patch
This increases coordination and rollback cost. Expand first so migration can proceed independently.
Duplicating business logic
Keep compatibility adapters thin and make one implementation canonical.
Removing the old path based on source search alone
Dynamic consumers, external clients, scripts, or delayed jobs may not appear in the same repository. Use runtime evidence when the boundary is distributed.
Forgetting the contraction stage
A migration is not complete while old interfaces remain indefinitely supported.
Creating two sources of truth
Compatibility representations should derive from canonical state rather than evolve independently.
Use overlap to reduce coordination
Parallel change trades temporary extra surface area for lower migration risk. It lets teams ship a compatible expansion, move callers in manageable steps, and remove the old contract only when evidence says it is safe.
The technique is deliberately boring, which is a strength. Refactoring shared interfaces becomes much easier when deployment order, repository boundaries, and rollback do not all have to align in one fragile moment.