Compatibility Is Part Of The Migration

A database migration is not complete when the new interface works. It is complete when the existing callers and the established behaviour still work too. That sounds obvious, but it is surprisingly easy to review a migration as a piece of database code and miss the wider contract it is changing. A cleaner function signature or a consolidated implementation can still be a production regression if an older application prepares a removed signature, or if the replacement quietly drops behaviour that only exists in one deployed variant.
The practical rule is simple: treat compatibility as part of the definition of done. Before removing an old entry point, identify who still calls it. Before replacing several implementations with one canonical version, compare what each deployed version actually does. A migration should make both questions answerable in the review itself, rather than leaving them for deployment to discover.
There are two contracts at work here. The first is the visible interface: function names, parameter lists, types and return values. These are easy to see in a schema diff, but a database often serves applications released on different schedules. An overload that looks obsolete from the database repository may still be prepared by an importer, background worker or older client. Removing it does not produce a graceful warning. It produces a failure at the point the statement is prepared, often far from the migration that caused it.
The second contract is behavioural. Two functions with the same signature may not mean the same thing. Years of fixes tend to accumulate around local rules, historical data and awkward edge cases. Copying the implementation that appears newest or cleanest can erase a small branch that protects an established outcome. The schema still deploys. The call still succeeds. The data is simply wrong, which is a much more dangerous form of success.
Compatibility wrappers are often the right bridge for the first contract. Keep the old signature, translate its arguments into the new shape, and delegate to the current implementation. This avoids duplicating the core logic while allowing independently deployed callers to move on their own timetable. The wrapper also gives the team a concrete deprecation point. It can be observed, documented and removed later when usage has genuinely reached zero.
The behavioural contract needs a different kind of discipline. When consolidating variants, build a small comparison of the decisions each version makes, especially around defaults, missing related records, inherited values and environment-specific branches. The goal is not to preserve every historical accident. It is to make any change intentional. If a branch is no longer correct, say why and test the new rule. If it still protects valid data, carry it forward explicitly.
Tests should follow the contract rather than merely exercise the new function. A useful migration test proves that an old caller can still prepare and execute its request, and that representative edge cases retain their established outcomes. Schema snapshots and deployment artefacts should then be updated from the same final definition. Otherwise the repository can describe one database while the release process produces another.
This changes the shape of review. Instead of asking only whether the new SQL is correct, ask what has been removed, who depends on it, and which behaviours differ between the old implementations. Those questions are cheap before merge and expensive after deployment.
The best migrations are not the ones with the smallest diff or the neatest signature. They are the ones that move the system forward without making existing, valid assumptions disappear by surprise. Compatibility is not clutter around the migration. It is part of the migration itself.


Share your thoughts