Almost nothing I build survives contact with its second requirement unchanged. The data model gains a field nobody anticipated, the vendor gets replaced, the “single region for now” becomes three. So the question I care about most when designing something isn’t “is this right?” It’s “how cheaply can I be wrong?”

That’s not pessimism. It’s the most reliable fact about software: the requirements you have today are the least you’ll ever know about the problem. Designs that last aren’t the ones that predicted the future correctly. They’re the ones that were cheap to correct when the future turned out differently.

Know which doors are one-way

Not all decisions deserve the same care. The most useful distinction I know is between one-way doors — decisions that are expensive or impossible to reverse — and two-way doors, which you can walk back through if you don’t like what’s on the other side.

Most decisions are two-way doors: the name of an internal function, the structure of a module, which library parses dates, the layout of a page. Agonising over them is waste. Make a sensible call, move on, and change it when you learn more.

A small number are one-way doors, and they’re usually about things other people depend on:

  • Data — schemas, identifiers, anything written to durable storage in a format others will read.
  • Public contracts — APIs, event formats, file formats, URLs that other teams or customers integrate with.
  • Irreversible side effects — money moved, emails sent, data deleted.
  • Deep commitments — a database engine, a cloud provider’s proprietary service at the heart of the system, a programming language for a large codebase.

This is where design time belongs. And the best move with a one-way door is often not to deliberate harder, but to find a way to turn it into a two-way door.

Good architecture isn’t about predicting the future. It’s about being cheap to correct.

Turn one-way doors into two-way doors

Most “irreversible” decisions can be made reversible with a little structure up front.

Change data in steps, not in one jump. A schema change that renames a column, deployed alongside the code that uses the new name, is a one-way door: if the deploy fails halfway, old code meets a new schema. The expand–contract pattern splits it into steps that are each safe on their own.

EXPANDMIGRATECONTRACTadd columndual-writebackfillswitch readsdrop oldeach step ships on its own — and every step but the last can be rolled back
Expand–contract turns a risky one-shot schema change into a sequence of small, reversible deploys.
-- 1. Expand: add the new column; nothing reads it yet.
ALTER TABLE customers ADD COLUMN display_name text;

-- 2. Deploy code that writes both full_name and display_name.

-- 3. Backfill existing rows (in batches on a large table).
UPDATE customers SET display_name = full_name WHERE display_name IS NULL;

-- 4. Deploy code that reads display_name. Keep writing both.

-- 5. Contract, once nothing reads the old column:
ALTER TABLE customers DROP COLUMN full_name;

Until step five, you can stop and go back at any point. Only the last step is a one-way door, and by then you have weeks of evidence that you don’t need to walk back through it.

Evolve contracts additively. For APIs and events, adding fields is safe and removing or renaming them isn’t. Design consumers as tolerant readers that ignore fields they don’t recognise, and producers that never remove a field while anyone still depends on it. When a breaking change is truly necessary, run the new version alongside the old one and migrate consumers deliberately.

Decouple deploy from release. Feature flags turn “ship the new checkout” from a one-way door into a switch. The code goes to production dark, gets turned on for internal users, then a few percent of customers, then everyone — and turning it off is a config change, not a rollback. Flags are debt, though: every one should have an owner and a removal date, or you’ll end up with hundreds of switches nobody dares touch.

Wrap deep commitments at the seam. When you depend on a vendor or a proprietary service, put a thin adapter between it and the rest of your code — one module that speaks the vendor’s API and exposes your domain’s language. Not a grand abstraction over every possible provider; just a single place where a replacement would have to happen.

Put the seams where the change will be

The idea is fifty years old. In 1972 David Parnas argued that systems should be split into modules according to the design decisions most likely to change, with each module hiding one such decision from the rest. It’s still the best rule I know. A seam — an interface that hides a decision — is valuable exactly where that decision is likely to change, and pure overhead everywhere else.

That cuts both ways. Too few seams and every change ripples across the codebase. Too many and you’ve built speculative generality: interfaces with one implementation, configuration for cases that never come, layers that exist only to be passed through. Those don’t make change cheaper. They make every change more expensive, because there’s more to read and more to update, in exchange for flexibility you never use.

So I look for evidence before adding a seam:

  • It has already changed. Something that has changed twice will probably change again. The rule of three is a good default: tolerate duplication until the third instance shows you what actually varies.
  • It’s controlled by someone else. Vendors, regulations, pricing, other teams’ APIs — things outside your control change on someone else’s schedule.
  • It’s a known unknown. When you genuinely don’t know yet — which search engine, which pricing model — a seam lets you decide later, with better information.

Everything else stays simple and concrete until the evidence shows up. Simple, well-tested code is itself easy to change; you don’t need to design the extension point before you know where it goes.

Keep the cost of change low everywhere else

Beyond individual decisions, a few habits keep a whole codebase easy to change:

  • Tests that check behaviour, not implementation. Tests written against public behaviour let you restructure freely underneath. Tests that mock every internal call turn every refactor into rewriting the tests too — so people stop refactoring.
  • Small, frequent deploys. A change that ships the same day it’s written is cheap to reason about and cheap to revert. A quarter’s worth of changes in one release is a one-way door built out of many two-way ones.
  • Decision records. A short note per significant decision — the context, what was chosen, what was rejected, and what would make us revisit it. That last line matters most. It turns “why on earth did we do this?” into “the condition we were waiting for has arrived.”
  • Delete things. Dead code, unused flags, abandoned endpoints. Everything that exists has to be read, understood and kept working during every future change.

The payoff compounds. Every decision that stays cheap to change keeps the next one cheap too, and a system made of reversible choices stays young far longer than one designed to be perfect on day one.


When I review a design, I ask:

  • Which decisions here are one-way doors, and have they had the care they need?
  • Can any one-way door be made two-way — with expand–contract, additive contracts, a flag or an adapter?
  • Are the seams where change is likely, based on evidence rather than imagination?
  • Is anything abstracted that has only one implementation and no reason to expect another?
  • Can we deploy this in small steps, and turn it off without a rollback?
  • Will someone reading this in a year know why it was built this way, and when to change it?

You won’t predict the tenth requirement. You don’t need to — you need a system that’s still cheap to change when it arrives.