An old shipping integration and its proposed replacement lived beside each other. Both had an interface. Both had a driver, request builders, and tests. That looked like a seam.
When the replacement was finally made canonical, the change still crossed dozens of files. The old interface disappeared, the new one moved, the canonical driver changed its pickup and shipment flows, and a parallel driver tree was deleted. Follow-up work then corrected a request shape and added serialization tests.
The two implementations had been separated from each other. They had not been made substitutable.
A replacement seam is not the space between two directories. It is the one contract and selection point through which both implementations can be exercised, compared, and reversed.
Two interfaces can describe two different systems
Before the cutover, the legacy interface looked roughly like this:
register shipment(credentials, customer, sdk_packages, comment)
get labels(credentials, barcodes)The newer interface described the replacement protocol:
register shipments(new_request)
get document cards(new_request)Each interface made its own implementation testable. That was useful. Neither gave the application one operation which both implementations could perform.
The differences reached beyond method names:
| Concern | Legacy path | Replacement path |
|---|---|---|
| request input | credentials and SDK objects | transport request objects |
| booking result | legacy response arrays | new protocol response arrays |
| document retrieval | barcode list | document request |
| pickup | SDK calls in the driver | connector operation |
| service catalogue | legacy repository | parallel repository |
A caller could not select old or new while keeping the same input and
result. Comparing them required knowing both protocols. Rolling back meant
restoring more than one adapter binding.
Interfaces create seams only relative to their callers. Two unrelated interfaces create two boundaries.
Start with the operation the application owns
The shared contract should not ask what both remote libraries happen to expose. It should ask what the application is trying to accomplish.
For a booking slice, that might be:
type BookingRequest = {
service: string
recipient: Address
parcels: Parcel[]
}
type BookingResult = {
trackingNumbers: string[]
documents: Document[]
}
interface BookingGateway {
book(request: BookingRequest): Promise<BookingResult>
}The old adapter translates that request into SDK packages and combines its registration and label calls. The new adapter translates it into the new protocol’s shipment and document requests. Both return the result the application already needs.
This contract does not pretend the remote systems are identical. Pickup cancellation, batch limits, asynchronous documents, or a service supported by only one protocol may need separate capabilities. The shared seam should cover the slice being replaced, not become a lowest-common-denominator client for everything either provider can do.
The test is simple: can an application caller switch implementations without learning a second remote vocabulary? If not, the replacement boundary is still below the caller which owns the decision.
A seam also needs one place to choose
Even a shared interface is incomplete if construction is scattered:
controller A -> new OldAdapter()
job B -> container resolves NewAdapter
command C -> feature flag around raw SDK callThe selection belongs at one composition boundary:
const gateway = mode === 'replacement'
? new ReplacementBookingGateway()
: new LegacyBookingGateway()Application code receives BookingGateway, never the mode or concrete
adapter. The switch can then be made by deployment, account, service,
capability, or a bounded rollout rule without duplicating the decision through
business code.
This also makes rollback precise. A rollback is not “revert the migration.” It is “route this supported slice through the previous adapter while retaining the same application contract.”
That promise needs limits. If the replacement writes state which the legacy system cannot read, or begins using a service the old protocol cannot book, the switch is no longer reversible. The seam should expose that boundary before rollout rather than leaving it in an emergency checklist.
Comparison must respect side effects
Running both implementations is tempting. It can also book two shipments, charge twice, send two notifications, or consume two identifiers.
Comparison therefore needs a mode appropriate to the operation:
pure mapping
run both against the same fixture
read-only request
shadow live input when remote policy allows it
write with provider validation mode
execute both only if neither creates durable effects
irreversible write
replay captured inputs through fakes or a sandbox;
compare one live result against expected invariantsThe retained migration does not provide evidence of shadow traffic, percentage rollout, or live dual execution. Claiming those techniques for that cutover would turn a useful design option into invented history.
It does provide request and response fixtures. Those are enough to compare translations without duplicating a real booking. The comparison should ignore remote noise such as generated identifiers and timestamps, then inspect the owned result:
same number of parcels
same service meaning
tracking identities present
documents present in the requested format
same recipient meaningParity does not mean byte equality between two protocols. It means equality of the application invariants the replacement promised to preserve.
The cutover diff reveals the missing seam
The retained change is useful because it does not support a neat success story.
The replacement protocol had already existed in a parallel driver tree for months. At cutover, the connector moved into the canonical tree, its interface grew pickup operations, the canonical driver changed, service definitions were merged, the SDK adapter was removed, and the parallel tree disappeared.
That proves a replacement was completed in retained code. It does not prove that the earlier parallel structure made the change safe.
Two follow-up changes are more revealing. One corrected the document request payload. Another pinned serialized replacement request shapes. They do not establish a production incident, but they show that protocol fidelity still needed work after the broad cutover.
A stronger seam would not have eliminated every change. The protocols really had different requests, capabilities, and results. It would have concentrated the cutover into three reviewable questions:
- Does each adapter satisfy the same application contract?
- Does the selector route the intended slice?
- Is the previous adapter still valid for that slice if rollback is needed?
The retained diff instead had to answer those questions while also merging two driver designs.
Contract tests make the old adapter useful
The legacy implementation is often treated as code waiting to be deleted. At the seam, it becomes the first executable definition of current behavior.
Write one contract suite and run it against both adapters:
successful booking
-> owned tracking and document result
remote rejection
-> owned failure with no successful result
missing document
-> explicit incomplete result, not an empty success
unsupported service
-> rejected before remote executionThen add adapter-specific tests for wire details. The shared suite does not replace assertions about authentication, request fields, or response parsing. It proves only that both transports deliver the application behavior needed for the cutover.
The synthetic reproduction contains two deliberately different remote shapes. Owned adapters translate them into one result, one selector chooses the active implementation, and the same contract suite runs against both. A captured-fixture comparison succeeds without issuing two writes.
There are cases where this machinery is not worth building. A single short-lived caller may be cheaper to change directly. A provider deadline may make rollback impossible. A replacement which deliberately changes product behavior may not have a meaningful parity target. In those cases, call the work a coordinated cutover and test the migration path. Do not claim safety from two interfaces which never promised substitution.
Before building a replacement beside the old code, write down the operation both must perform, the result callers own, the point which selects one, and the state which limits reversal. Without those four things, parallel code buys separation. It does not yet buy a seam.