Process - Oct 2026

Close-up of a glass revolving door showing its central spindle, angled panels, and circular floor track

Optimize for Reversibility

We don't always know the right decision when we make it. Good engineering leaves room to learn, change direction, and recover without rebuilding everything around us.

  • software
  • development
  • design
  • architecture

During my time at AWS, I heard decisions described as one-way doors and two-way doors. It’s an idea closely tied to Amazon’s broader “Day 1” culture: move quickly, stay willing to experiment, and recognize that not every decision needs to be treated as permanent.

Jeff Bezos described the distinction as Type 1 and Type 2 decisions in his 2015 letter to Amazon shareholders.

A two-way door is a decision you can walk back through. If you’re wrong, you learn something, reverse course, and try something else.

A one-way door is different. Once you walk through it, reversing the decision is difficult, expensive, or sometimes impossible.

There’s another phrase I’ve heard throughout my career that describes essentially the same idea in less polished terms:

Don’t code yourself into a corner.

Software engineering is full of decisions we make before we have all the information we’d like. We choose an API, a data model, a dependency, an abstraction, or an architecture based on what we know today. Sometimes we’re right. Sometimes six months of production traffic teaches us something the design document couldn’t.

I’ve found that some of the most painful engineering decisions aren’t necessarily the ones that were wrong. They’re the ones that became difficult to change before we had enough information to know whether they were right.

I don’t expect us to predict all of that. I’d rather leave enough room to be wrong without making it catastrophic.

Most Decisions Don’t Need to Be Permanent

Engineers are often rewarded for making decisions. We evaluate options, discuss tradeoffs, choose a direction, and start building. At some point, continued debate costs more than making a reasonable choice and learning from it.

The problem starts when making a decision also makes it unnecessarily difficult to reverse.

Suppose an application needs to store some data. We could hide the storage mechanism behind a small interface:

class UserRepository(Protocol):
    def get(self, user_id: str) -> User | None:
        ...

    def save(self, user: User) -> None:
        ...

Or the rest of the application could talk to the database directly:

user = dynamodb.get_item(
    TableName="users",
    Key={"id": {"S": user_id}},
)

There’s nothing inherently wrong with using DynamoDB directly. The difference is how much of the system has learned about that decision.

Look at what this one caller now knows: the table name, the key schema, and DynamoDB’s attribute-type encoding (that "S" means string). Multiply that by every place in the application that needs a user, and the decision stops being “we use DynamoDB.” It becomes “the application is structured around DynamoDB.” Changing the storage model later means finding and changing every one of those places.

Choosing DynamoDB was an easy decision to make.

Structuring the whole application around it is a much harder one to undo.

That interface only contains part of the cost. Moving the data still means dealing with access patterns, consistency, backfills, and a safe transition. A boundary can keep storage details out of callers; it can’t make two databases behave identically.

Reversibility Is About the Cost of Being Wrong

It’s tempting to think of reversible design as predicting what might change. Maybe we’ll switch databases. Maybe we’ll replace this API. Maybe we’ll need another implementation someday. Follow that thinking far enough and you end up building abstractions for futures that never arrive.

I’ve fallen into that trap too. It’s easy to justify another abstraction by imagining all the ways the system could change. My imagination is quite capable of creating more work for me. Given enough of it, almost anything can look like it needs an escape hatch.

What helps is narrowing the question to the decisions where being wrong would be unusually expensive.

Easier to reverse Harder to reverse
A small internal function A data format written into millions of persisted records
A library used behind one interface A library whose types appear throughout the codebase
An internal API that can evolve with its callers A public API used by people you don’t control

The important question isn’t “Could this ever change?” Almost everything could.

A better question is “If we’re wrong, how difficult will this be to change?”

That changes how much engineering the decision deserves.

Keep Decisions Local

One of the simplest ways to preserve reversibility is to limit how far a decision can spread. If ten modules know which HTTP client you chose, replacing it is a ten-module problem. If one module knows and the rest of the application talks to that module, the same change might be a one-module problem.

This doesn’t require turning every dependency into an elaborate abstraction. Sometimes it’s just a function:

def fetch_customer(customer_id: str) -> Customer:
    response = requests.get(f"{BASE_URL}/customers/{customer_id}", timeout=5)
    response.raise_for_status()
    return Customer.from_dict(response.json())

The caller gets a Customer rather than an HTTP response. Swapping requests for another client can stay inside this function, as long as we preserve the behavior callers rely on, including failures. A move from REST to GraphQL may fit behind the same boundary too, but only if it can still honor that contract. A function name doesn’t hide differences in freshness, permissions, or which customers can be found.

That’s also why the UserRepository above doesn’t have to be a heavyweight pattern. The interface isn’t the point. Keeping the DynamoDB-specific knowledge in one place is.

I’ve found this a useful way to think about coupling. The problem isn’t simply that one thing depends on another. Software has dependencies, and that’s unavoidable. The question is how much of the system has to change when that dependency changes.

A decision contained in one place is easier to reverse than one that has leaked everywhere.

Delay the Decisions You Don’t Need Yet

Sometimes the most reversible decision is the one you haven’t made. That doesn’t mean avoiding decisions indefinitely. It means recognizing when the system doesn’t actually require one yet.

This is something I’ve had to get more comfortable with over time. Making a decision can feel like progress, especially when there’s pressure to settle on an architecture. But deciding earlier isn’t automatically better if all we’re doing is committing ourselves while we still know very little.

Imagine we’re building a reporting feature and expect that it may eventually need background processing. We could immediately choose a queue, define a message schema, create infrastructure, build retry handling, and design around the semantics of that particular system.

If users can reasonably wait for the report and the work fits within the request’s time limit, we might generate it inline first. Keep report generation separate from how work reaches it, and delay choosing a queue until we need one.

That doesn’t make asynchronous processing a drop-in replacement. Returning before the report is ready changes the contract: callers need a way to track completion, failures need somewhere to go, and retries mustn’t create duplicate results. If the feature already needs durable background work, those requirements are a reason to choose a queue now.

The point isn’t that simple is always better. It’s that every decision introduces constraints. Choosing later, when we know more about throughput, failure modes, ordering requirements, and operational behavior, can be better than making a sophisticated decision early based mostly on guesses.

Information has value. If waiting for more of it is cheap, preserving the option to wait can be valuable too.

Prefer Experiments With Exits

There are times when we genuinely don’t know which approach will work. That’s a good time to think about the exit before thinking about the commitment.

  • Can we try the new dependency in one service before making it the standard everywhere?
  • Can we put the new implementation behind a feature flag?
  • Can we migrate a small percentage of traffic first?
  • Can the previous implementation still read data written during the experiment?
  • Can we measure the result before removing the previous path?

None of these techniques guarantee the experiment will work. They help when we’ve checked that the exit actually works. A feature flag can switch code paths; it can’t undo a charge, retract an email, or restore data we’ve overwritten.

For a data migration, I’d want to know which version can read each format, how we’ll detect missing or inconsistent records, and what happens to writes made after the rollout starts. Keeping the old code available isn’t enough if it can no longer use the data.

Before starting, agree on what success looks like, what would make us stop, and who can make that call. Test the way back on a small slice before widening the rollout. Once we’re confident in the new path, remove the flag and the old implementation; an exit we keep forever becomes another system to maintain.

I like experiments where failure leaves us with information instead of cleanup. If an idea doesn’t work, we should be able to say, “Well, we learned something,” rather than, “Now we have six months of migration work.”

Being able to stop is part of what makes the experiment useful, especially when we’re still learning whether the idea deserves a place in the system.

Some Doors Really Are One-Way

Not every decision can remain reversible. Eventually data gets persisted. APIs get published. Customers depend on behavior. Infrastructure accumulates state. Teams build systems on top of the choices we’ve made.

Sometimes the one-way door is unavoidable, and that should change how we approach the decision. A choice that’s cheap to reverse can usually be made quickly with incomplete information, because we can rely on feedback to correct us. A choice that’s expensive to reverse deserves more scrutiny before we commit. That might mean more design work, a prototype, a migration plan, compatibility guarantees, or simply spending more time understanding the consequences.

The mistake isn’t making irreversible decisions. Software would never ship if we refused to make them.

The mistake is treating every decision as though it has the same cost of being wrong.

Reversibility Has a Cost Too

There’s an obvious trap here. If every decision needs an interface, adapter, feature flag, migration layer, compatibility shim, and fallback implementation, we’ve created another kind of problem: we’ve made the system complicated today to protect ourselves from every imaginable tomorrow.

At that point, keeping our options open has become a problem of its own. That’s fear of commitment.

An abstraction nobody needs has a maintenance cost. A feature flag nobody removes becomes another execution path. A compatibility layer can outlive the system it was supposed to help migrate.

I’ve found this is where “keep our options open” can become an excuse for complexity. Flexibility sounds universally good until you’re maintaining five different paths through a system because nobody ever wanted to close a door.

Sometimes the correct decision really is to choose something and build around it. Optimizing for reversibility doesn’t mean maximizing flexibility. It means preserving flexibility where uncertainty and the cost of change justify it.

Constrain What You Understand, Keep Open What You Don’t

There’s an interesting tension here with another principle I care about: making the wrong thing hard to do. If we encode rules into types, APIs, defaults, and automation, aren’t we making the system less flexible?

Sometimes. And that’s exactly what we want when we understand the rule. If a resource must always have an owner, making owner required removes a state we know is invalid. But if we’re still discovering what ownership means, encoding a complicated ownership model throughout the entire system might be premature.

The difference is confidence.

When we understand an invariant, constraints make the system safer. When we’re still exploring a decision, reversibility gives us room to learn.

I don’t think those ideas compete with each other. They’re answers to different levels of certainty. Encode what you know. Leave yourself room around what you don’t.

Working out which situation we’re in is the part I wouldn’t expect an interface or a pattern to decide for us.

Leave Yourself a Way Back

We make software decisions with incomplete information. Requirements change. Traffic behaves differently than expected. Dependencies disappoint us. Customers use features in ways we didn’t anticipate. Something that looked elegant on a whiteboard turns out to be awkward in production.

None of that means the original decision was bad. It means we learned something.

The systems that age well aren’t necessarily the ones where engineers predicted every future requirement correctly. They’re often the ones where learning something new doesn’t require tearing everything apart.

Keep uncertain decisions local. Delay commitments when waiting gives you useful information. Experiment in ways that give you an exit. Spend more time on the doors that really do lock behind you.

And when you’re deciding how much flexibility a design needs, ask a simple question:

If we’re wrong, how hard will it be to change our mind?

You don’t need to predict every turn the software will take.

Just try not to code yourself into a corner.