Expand and Contract
The challenge with most breaking changes is that clients of your old shape are still out there calling while you’re working to replace it with the new shape.
Expand and contract (also known as parallel change) is my favorite pattern when I need to change an interface or schema without downtime. Instead of ripping out the old thing, you add the new thing alongside it and migrate callers over one piece at a time until you’re able to remove the old thing.
Expand
The expand phase creates the new shape next to the old one. An API’s new shape might involve adding a new field or endpoint. With databases, this will often involve adding nullable columns or an entirely new table instead of changing the old one.
Here’s a small example. Imagine users.name needs to be split into users.first_name and users.last_name.
Add both new columns, but keep your application reading from the old column. Writes go to both the old and new columns, though.
flowchart LR
App["<strong>Application</strong>"] -->|read| Old[("<strong>Old</strong><br/><span class='mermaid-detail'>users.name</span>")]
App -->|write| Old
App -->|write| New[("<strong>New</strong><br/><span class='mermaid-detail'>users.first_name<br/>users.last_name</span>")]
Users are oblivious, and the new columns start getting real data.
Migrate
This is the slow step. You backfill existing rows and migrate callers piece by piece. Feature flags are awesome here, as you can toggle writes and reads separately. You can start writing to both fairly early, and reads can come later when you’re ready.
Take our toy example: a backfill job scans existing rows, writing splits of users.name, and your application writes to both columns. After verifying the data in the new columns, you switch reads over to them.
flowchart LR
Backfill["<strong>Backfill job</strong>"] -->|copy and split| New[("<strong>New</strong><br/><span class='mermaid-detail'>users.first_name<br/>users.last_name</span>")]
App["<strong>Application</strong>"] -->|read| Old[("<strong>Old</strong><br/><span class='mermaid-detail'>users.name</span>")]
App -->|dual write| Old
App -->|dual write| New
The order can vary. Sometimes you dual-write and then backfill, and sometimes you backfill, then update the clients. Regardless of what order you pick, every step in between has to be backward compatible and deployable.
This stage often gets glossed over too quickly. You’ll often need throwaway code that simply doesn’t belong in your final solution, like adapter classes or compatibility endpoints whose whole job is translating between the old shape and the new. This is known as transitional architecture: code you add to complete the change safely, then throw away. Strangler fig migrations leverage the same concept, just at a higher level.
Rollback is worth thinking about too. At first, rollback is simple. Once new code writes data that the old path can’t represent, rollback requires extra effort, and forcing it at this stage means you’ll lose that data. That’s okay, but be sure to decide that knowingly instead of accidentally.
Contract
Once all callers are reading and writing through the new path, you can delete the old one. Drop the legacy column and delete the adapter code. Now your whole system reads and writes a single shape again.
flowchart LR
App["<strong>Application</strong>"] -->|read and write| New[("<strong>New</strong><br/><span class='mermaid-detail'>users.first_name<br/>users.last_name</span>")]
Cleanup["<strong>Cleanup</strong>"] -->|drop| Old[("<strong>Old</strong><br/><span class='mermaid-detail'>users.name</span>")]
This is the most commonly skipped step. Once cutover is complete, the old path sticks around for “just a little longer”, and soon you’ll find yourself supporting both shapes forever. If you never contract, you’ve just doubled the work.
Is it worth it?
Writing code that you’re just going to throw away feels wasteful, but in my experience it pays for itself. Releases are smaller and easier to roll back. You can ship parts of the new system before the migration is finished, which means you can validate things earlier.
The biggest win is getting into the practice of turning one scary change into a series of small, reversible changes. Each step should be able to be released on its own, and every line of temporary code should have an owner and a deletion plan.