HM NayemHM Nayem
All blogs

The architecture decisions that get expensive to undo

·17 min read

Some technical decisions are cheap to change later and some are nearly impossible. Knowing which is which, before you commit, is one of the most useful things a technical person can learn. It tells you exactly where to slow down and where to move fast. Most decisions do not deserve your best thinking, and giving it to them anyway is how teams move slowly for no reason. A small number do. They are the ones that get woven into everything, and once real usage settles on top of them, taking them back is a project, not an edit.

This is the catalog of those decisions. Not the general economics of getting one wrong, which is its own story. This is which ones lock in, and the concrete mechanism that makes each one hard to reverse. What has to be migrated. What grew across it. Who depended on it. Learn the list and you know where the doors are.

Here is the whole set:

  • Your core data model
  • How you handle identity and permissions
  • Where you draw your service boundaries
  • Your public API contract
  • Deep dependencies on a single vendor
  • How you store and migrate data
  • The conventions the whole team builds on

None of these look dangerous on the day you make them. That is the point. They look like reasonable early choices, and they are, right up until the moment the whole product is resting on them. Each section below is one item: why it locks in, and what specifically you would have to undo to change it later.

Your core data model

The shape of your central data is the stickiest decision on this list. It is the structure of the things your business is actually about. The customer, the order, the document, the account, whatever the real nouns are. Every feature reads them. Every screen renders them. Every query assumes their shape.

On the day you draw it, changing it is free. It is a diagram. Nobody depends on it yet. Then you ship, and real data starts living in that structure, and the cost of changing it stops being free and never becomes free again. It grows with every record.

That is the mechanism. Changing a data model that holds live data is not a code change. It is a migration. You have to move every existing record from the old shape to the new one, and you have to do it on data people are trusting you with right now. That carries three costs that a code change does not. There is downtime risk, because the system may have to pause while the data moves. There is corruption risk, because a migration that goes wrong on a million records can lose or scramble data you cannot get back. And there is the plain, careful work of writing and testing the migration itself, which grows with the size of the table.

The trap is that the early version always looks right. You model the business as you understand it on day one, and on day one you understand a simplified version of it. The real business has variants and exceptions and cases that only show up after you have watched it run for a year. When one of those cases does not fit the model, the pressure is never to change the model, because changing it means the migration. The pressure is to bolt on a workaround. A special case becomes an extra field. An unusual relationship becomes a second record linked to the first. The model does not get corrected. It gets coated in exceptions, because correcting it is expensive and coating it is cheap.

So the model you sketch early is the one you live with for years. Not because it was right. Because it filled up. This is the one decision on the list worth genuine thought before anything depends on it, and it is worth an experienced second opinion, because it is the hardest thing here to take back once real data has moved in.

How you handle identity and permissions

Identity is who a user is. Permissions are what that user is allowed to do. How accounts relate to organizations, how people group into teams, how roles map to access. It sounds like one feature among many. It is not. It reaches into everything.

The reason it locks in is that auth is not a module you can swap. It is woven through the whole product. Every screen that shows data first asks who is asking and whether they are allowed to see it. Every action checks a permission before it runs. Every query is scoped, quietly, to what the current user can touch. That check is everywhere, which means the assumptions behind it are everywhere too.

Now watch what happens when the early assumption was too simple. Say you built the product for individual users. One person, one account, they own their own data, done. It is the right call for launch and it keeps everything simple. Then you land a customer who is a company. They have twenty employees. They want shared data, an admin who can see everything, members who can see only their own work, and someone who can add and remove people. None of that exists in your product, because your product only ever knew one kind of user with full access to their own island.

Retrofitting a real permission model onto an app that never had one is one of the most pervasive changes you can make. It is not a screen you add. It is a question you now have to ask in every place that previously did not need to ask it. Every query that assumed one owner now has to understand roles. Every screen that showed everything now has to hide the parts this member cannot see. Every action that anyone could take now has to check whether this particular person is allowed to take it. You are not adding a feature. You are threading a new question through code that was written on the assumption the question did not exist.

The cheap insurance is to leave room early. If there is any real chance you will sell to teams or companies, model an organization above the user from the start, even if every organization has exactly one member for the first year. An empty layer you designed for is nearly free. The same layer, retrofitted after everything assumed it was not there, is one of the most expensive changes on this list.

Where you draw your service boundaries

At some point you divide the system into parts. This piece handles billing. That piece handles content. This other piece handles notifications. Where you put the lines between those parts is a decision, and it is one you make early, often without noticing you are making it.

The lines lock in because code grows across them. When two parts are cleanly separated, everything one part needs from the other has to pass through a deliberate connection. That connection is a seam. Over months, more and more traffic runs through each seam, and the two sides shape themselves around each other. They come to assume each other's timing, each other's data, each other's quirks.

Moving a boundary later means cutting through all of that. Say you drew the line so that billing and accounts were one part, because early on they felt like the same thing. A year in, they clearly are not, and you want to split them. That is not a refactor you do in an afternoon. Everything that grew assuming billing and accounts share the same internals now has to be untangled. Every place that reached across the old, invisible line has to be given a real, deliberate connection instead. You are separating two things that spent a year growing into each other, and they did not grow neatly.

You do not need perfect boundaries. Perfect is not available anyway, because you cannot see the whole future shape of the system from the start. You need roughly right. Roughly right means the biggest, most obvious separations line up with the real seams of the business, so the parts that change for different reasons are not welded together. Get that roughly right and later adjustments are annoying but survivable. Get a major boundary wrong and moving it is a genuine undertaking, because you are not moving a line. You are unpicking everything that crossed it.

Your public API contract

The moment external people write code against your API, it stops being your code and starts being a promise. A partner integrates it. A customer builds a workflow on it. Someone ships their own product on top of yours. Every field you return and every behavior you have becomes something they now depend on.

This is the decision that locks in the fastest and the hardest, because you no longer control who depends on it. You control your own code. You can change it whenever you like, because you can find every place it is used and update them together. You cannot do that with a public API. You do not know who is using which field. You cannot see their code. You cannot update it for them. The dependency lives outside your walls, and you cannot reach it.

So the asymmetry is total. You can almost always add to a public API. A new field, a new endpoint, a new optional parameter. Adding breaks no one, because the people relying on the old shape never asked for the new thing. But you can rarely remove or change anything. Rename a field and every integration reading the old name breaks. Change what a value means and every workflow built on the old meaning silently does the wrong thing. Tighten a behavior someone was quietly relying on and their product stops working, and the first you hear of it is an angry message asking what you did to them.

Because you cannot take things back, the first public version is not a first draft. It is a commitment. Whatever you ship, you will support for a long time, including the parts you regret, because the people who built on those parts did nothing wrong and breaking them is not an option you have. Treat the first public API the way you would treat a contract you have to honor for years, because that is exactly what it is.

Deep dependencies on a single vendor

Using a third party is not the risk. You should use third parties. You will never build everything yourself, and you should not try. The lock-in is a narrower thing. It is building your product so deeply around one specific vendor that their particular way of working gets baked into your core.

There is a spectrum here, and only one end of it hurts. On the safe end, you use a vendor through a thin layer, for a job that is clearly not your product. On the dangerous end, a vendor's specific model of the world becomes the model your own code is written in. Their concepts become your concepts. Their assumptions about how the work is shaped become assumptions your core cannot function without. At that point the vendor is not a tool you use. It is part of how your product thinks.

That is what makes leaving expensive. When a dependency is shallow, switching is swapping one connection for another. When it is deep, switching means rewriting every part of your system that was shaped around the vendor's way of doing things, because your code does not just call them, it thinks in their terms. And you rarely get to choose the moment. The vendor raises their price, or drops the capability you were built on, or gets acquired by someone who does not care about your use case. Now you have to leave, on their timing, and leaving means rebuilding the parts of your core that were quietly assuming they would always be there.

The rule that keeps this reversible is about depth, not avoidance. Use vendors freely for the things that are not your product. Keep them at arm's length through a thin layer you own, so that switching is a contained job and not a rewrite. The deeper a single vendor runs toward your core, the more of your future you have handed to a company you do not control, and the more of your own product you will have to rebuild the day you want it back.

How you store and migrate data

The shape of your data is one decision. How you store it and how you change it over time is a separate one, and it is quietly foundational in its own right. It is not what your data looks like. It is whether your system was ever built to expect that data would need to change.

Here is the difference it makes. Data always evolves. A field you did not need becomes required. A value that was one thing splits into two. A structure that made sense at ten thousand records has to change at ten million. None of that is avoidable. The only question is whether your system was built expecting it or built pretending it would never happen.

A system built with no thought for change makes every one of these harder than it needs to be. There is no established way to alter live data safely, so every structural change is invented from scratch, under pressure, on data you cannot afford to lose. There is no path to run a change gradually, so every change is all at once, which means downtime and risk. Nobody has ever practiced doing this here, so the first real migration is also the first time anyone finds out what breaks. Every future structural change inherits that cost, forever, because the foundation was never built to be changed.

A system built expecting change is a different world. Migrations are a normal, routine thing, not an emergency. There is a known, tested way to move live data from one shape to another. Changes can run gradually, so a mistake is caught on a slice instead of on everything. The result is not that your data stops needing to change. It is that changing it stays a routine task instead of becoming a crisis every time. You cannot make data stop evolving. You can decide, early and once, whether evolving it is normal or terrifying.

The conventions the whole team builds on

The last one is the quietest, which is exactly why it locks in so hard. Early on, a small team settles on how things are done here. How the code is organized. How errors are handled. How the pieces talk to each other. The shared patterns everyone follows without being told. These feel like small choices when a couple of people make them casually over a week. They become load-bearing as the codebase grows.

They lock in because everything written afterward assumes them. A convention is not one decision sitting in one file. It is a decision that every later file was built to match. Once a thousand files follow a pattern, that pattern is not a choice anymore. It is the ground the whole codebase stands on, and each new file makes the ground a little more fixed.

That is what makes a foundational convention nearly impossible to change once the codebase is large. Changing it is not editing one thing. It is touching every place that followed it, which by then is a great deal of the code. And there is a subtler cost. A team carries the old convention in their hands. They reach for it automatically. Changing the convention means changing a habit thousands of files and every person already have, which is far slower than changing any single piece of code, because you are not fighting the code. You are fighting momentum.

You will not get every convention right, and you do not need to. But the few truly foundational ones, the ones every file will touch, are worth choosing deliberately while the team is small and the cost of choosing is nothing. They feel minor exactly when they are cheapest to set. They become expensive precisely when they matter most.

What to do with this list

The point of this list is not to make you cautious about everything. It is the opposite. It is a permission slip to be reckless almost everywhere.

For anything not on this list, move fast. Pick something reasonable and go. Do not hold a meeting. Do not get a second opinion. Do not spend your best thinking on a choice you can reverse in an afternoon, because being wrong about it costs you an afternoon and nothing more. Most decisions are like this, and treating them as if they were foundational is how good teams turn slow for no reason. Speed is the right default, and it is right precisely because the door is still open behind you.

Save your care for the seven. The data model, identity and permissions, service boundaries, the public API, deep vendor dependencies, how you store and migrate data, the conventions the whole team builds on. These are where a second opinion is worth the time. Where an extra week before you commit is trivial next to what it costs to undo the choice later. These are the doors that lock behind you, and the whole skill is knowing which ones they are before you walk through.

I learned this the long way. I founded Stack Learner, an ed-tech platform, and over roughly a decade I built and rebuilt it more than once. The lesson that stuck was not that I should have been careful about everything. I was not, and being careful about everything would have buried the whole thing before it started. The lesson was narrower and more useful. The freedom to experiment freely everywhere else came from being disciplined about exactly this small set of foundational choices. Because those were solid, everything built on them could stay cheap to change. We could try things, throw them away, and try again, because none of the fast decisions were load-bearing. The discipline in a few places is what bought the recklessness everywhere else.

That is the whole trade. Know which door locks behind you, and you can be reckless everywhere else with a clear conscience.

HM Nayem
Founder @ Stack Learner · Director of Engineering @ Toptal
Work with me
Newsletter

Notes on building things that last

Occasional writing on product, engineering, and building a company, sent when I have something worth saying. No noise.

No spam. Unsubscribe anytime.