omeryanbas.com

Ömer Yanbaş

General Manager, Ticofab Yazılım

Practice

Long jobs get finished in small reversible steps

How to cut a large piece of work into steps that can each ship and each be undone, with feature flags, expand and contract migrations and a real rollback.

A rewrite has been nearly done for six weeks. The branch has a hundred and eighty changed files, the review is a formality because nobody can hold that much in their head, and merging it is a single event that either works or ruins a Thursday. If it goes wrong the only way back reverts everything, including the parts that were fine. The work itself was not the problem. The size of the step was.

What actually happens with a long branch

Three costs grow together while a branch sits open, and they do not grow gently.

The conflict surface grows with time and with files. Every change someone else lands is a chance that your branch and the mainline disagree, and the merge that resolves those conflicts is the least reviewed code in the project, written under time pressure by somebody who wants to be finished.

The review degrades. A twenty line diff gets read. A two thousand line diff gets scrolled. The approval is real, the reading is not, and everybody involved knows it.

Reverting stops being possible. A single merge with everything in it can only be undone as a unit, so the cost of backing out one bad decision is throwing away six weeks of good ones. Faced with that, teams patch forward under pressure instead, which is how a bad Thursday becomes a bad month.

There is a fourth cost that is easy to miss. You learn nothing until you integrate, so the estimate does not improve while the work happens. The team says four weeks in week one and still says two more weeks in week five, because nothing has been put in front of a real system to be wrong about.

The unit of work

A step is worth shipping when all three of these are true:

  1. It can be deployed on its own, without anything else going out at the same time.
  2. If you stop there permanently, the system is still correct. Not finished, correct.
  3. There is a written way back, and somebody has run it.

The third condition is the one that gets skipped, and it is the one that makes the difference. A rollback you believe in is not a rollback. Write the down migration at the same time as the up migration, and run it once against a copy of real data before the change goes anywhere. This is the same habit as testing that your backup restores, and it fails for the same reason when nobody tries it: the thing that was never exercised does not work on the day it is needed.

Cutting the work

Feature flags let the new path exist in production without being used. The old path stays exactly as it is, the new path ships dark, and turning it on is a separate decision from deploying it:

// both paths ship; the flag decides, and the flag has an owner and an expiry
export function price(cart, flags) {
  if (flags.enabled('pricing_v2')) return priceV2(cart);
  return priceV1(cart);
}

// flags.json
// { "pricing_v2": { "owner": "billing", "added": "2026-06-08", "remove_by": "2026-07-20" } }

That comment about the expiry matters more than the flag. A flag with no removal date becomes permanent, and permanent flags multiply: three of them make eight possible paths through the code and your test suite covers two.

Schema changes get the same treatment through expand and contract. One risky migration becomes six safe deploys:

/* step 1, expand: additive and reversible, nothing reads it yet */
alter table invoice add column currency text;

/* step 2 is code: write both columns, keep reading the old one */

/* step 3, backfill in resumable batches, run until it reports zero rows */
with batch as (
  select id from invoice where currency is null order by id limit 5000
)
update invoice i set currency = 'EUR'
from batch b where i.id = b.id;

/* step 4 is code: read the new column, still write both */
/* step 5 is code: stop writing the old column */

/* step 6, contract: only after a full release where nothing touched the old column */
alter table invoice alter column currency set not null;
alter table invoice drop column legacy_currency;

Steps one to five are all reversible by redeploying the previous version. Step six is not, which is why it is alone at the end, and why it happens a release later rather than in the same afternoon.

The third technique is to keep the old path alive for one release. Old endpoint, old queue name, old URL, old message shape: all of them keep answering while the new one takes over. The reason is arithmetic rather than courtesy. Deploys are not instant, caches hold old responses, queues hold old messages, and some clients will not have reloaded. A browser holding an old permanent redirect is the extreme version of the same problem, which I went through in a cached 301 after a redesign.

How to check a step is really reversible

Do not reason about it, run it. The exercise takes ten minutes and is the only thing that turns a plan into a guarantee:

# deploy the step, then take it back, then put it in again
./deploy.sh release-41 && ./smoke.sh
./deploy.sh release-40 && ./smoke.sh
./deploy.sh release-41 && ./smoke.sh

If the middle line fails, the step is not reversible and you have learned that on a quiet afternoon rather than during an incident. The same applies to the revert of a merge: git revert -m 1 <merge> should compile and pass the tests, and if it does not, the merge was too big.

Check the build exit code before anything restarts, because a rollback that deploys a failed build is worse than no rollback at all. That mechanism is worth getting right once and is described in check the build exit code before you restart.

What to watch out for

  • Flags that outlive their purpose. Put the removal in the same plan as the rollout, with a date, and let the build fail when the date passes. A flag registry nobody prunes is technical debt with a friendly interface.
  • Reversible code, irreversible data. A deploy can go backwards. A dropped column and a deleted row cannot. Anything destructive belongs in the last step, after a release where you proved nothing reads it.
  • Steps that ship correctly but leave the product half built in front of a user. Half a feature belongs behind a flag, not behind a screen that half works.
  • The old path that never gets removed. If removal is not scheduled, you have not simplified anything, you have added a second way of doing it and kept both.
  • Backfills that lock the table. Batch them, make them resumable, and run them outside peak hours. A backfill that has to complete in one transaction is a long branch wearing different clothes.

What you can promise

This changes the conversation about dates more than it changes the code. Instead of one date six weeks out, which is a guess dressed as a commitment, you can offer a sequence: this step next week, that one the week after, and you can stop after any of them with a working system. Scope changes cost one step rather than the whole plan, because nothing downstream has been built on top of an assumption yet.

The honest part is that the total is rarely faster. Six deploys take more elapsed time than one, and writing the rollback is work that produces nothing a user sees. What you buy is that the project is never stuck, never unreviewable and never one bad merge away from a week of recovery. On a long job that is worth more than speed, and it is the difference between a date you defend and a date you keep.

Questions and answers

What makes a step reversible?
Three things: it deploys on its own, the system is correct if you stop after it, and there is a way back that you have run rather than imagined. Code is reversible by redeploying the previous artifact. Data is not, which is why dropping a column and deleting rows belong at the very end of a plan and never in the middle.
Why is a long lived branch risky?
The chance of conflict grows with every day and every file, the review gets less careful as the diff grows, and the whole thing lands as one event that cannot be partially undone. You also learn nothing until the end, so your estimate does not improve while the work is happening, which is precisely when you need it to.
How do I change a database column without downtime?
Expand and contract. Add the new column as nullable, write to both columns, backfill in resumable batches, switch reads to the new column, stop writing the old one, and only then drop it. Each of those is a separate deploy and each one can be rolled back by redeploying the previous version.
Do feature flags not just create their own mess?
They do if they live forever, because every flag doubles the number of paths and nobody tests the combinations. Treat a flag as a temporary scaffold with an owner and a removal date recorded when it is created, and remove it in the release after the one that turned it on.