Article · Blog
The pattern you won't refactor because it's working
Bad technical decisions rarely blow up. They just stay. This article is about the kind of code that survives not because it's good, but because it works just enough to never become a priority.
The pattern you won't refactor because it's working
At a fintech I worked at, there was a coordinator that was written in two days to solve an urgent navigation problem. It was functional. It was in production. Nobody quite understood what it did, but navigation worked.
Two years later, that coordinator was still there. With thirty more method calls added by five different people. Each one solving their own problem without touching what was already there.
It wasn't technical debt in the classic sense. It was something worse: code that was never going to be refactored because it was always going to work.
The trap of code that never breaks
There's a category of technical decision the industry almost never talks about: the decision that doesn't fail in any visible way.
When something breaks in production, you investigate, fix it, sometimes refactor. The feedback loop is short. The problem has a name, an owner, a resolution.
But when something works poorly for years without blowing up, the feedback loop disappears. The code stays. The team grows. New people arrive, read the code, don't fully understand it, but the behavior is correct. So they add one more layer on top.
That's the pattern that kills systems slowly: not the bad code that fails, but the bad code that survives.
Why this happens with specific design patterns
Some patterns are particularly prone to this problem.
The Coordinator is one of them. The idea is good: separate navigation from presentation logic. The problem is that Coordinator has no natural boundary of responsibility. You don't feel it growing too large until it has five hundred lines and eight delegates.
The same thing happens with Builder when it starts containing conditional business logic. With Factory when it starts importing frameworks that shouldn't be in that layer. With Singleton when it becomes the default place to store global state that "is only temporary."
These patterns have something in common: they solve a real problem in the first version, and accept anything in the versions that follow.
The real problem isn't the pattern
I used to blame the pattern when I saw this kind of degradation. I was wrong.
The pattern isn't at fault. The problem is the absence of an explicit boundary that forces the conversation when the code is growing beyond its original intent.
In an iOS app with UIKit and Clean Architecture, a well-defined ViewModel has clear boundaries: it receives input, exposes output, knows nothing about UIViewController. When someone tries to put presentation logic in it, the typing itself resists. The conversation happens before the merge.
But a generic Coordinator has no such resistance. You can add anything that doesn't have another obvious home. And there's always something without an obvious home.
The difference between a pattern that holds up well over time and one that degrades isn't the elegance of the pattern itself. It's whether it has a boundary that generates natural friction when someone tries to violate it.
What actually works in practice
The decision that helped me the most was to stop evaluating patterns by the elegance of the idea and start evaluating them by the resistance they offer to disorganized growth.
When I was at Compass UOL working on signup and profile editing modules, my team and I adopted a simple rule: each module had a single typed entry point. Anything that needed to cross the module boundary had to go through there. If the signature of that entry point started getting large, it was an explicit signal that something was wrong.
It wasn't an elegant solution. It was a bureaucratic rule. But it turned a silent problem into a visible one.
Code that grows without resistance is never going to be refactored. Code that generates visible friction has a chance.
Concrete implementation
In practice, this translates into a few specific architectural decisions.
First, explicit interfaces between layers with types that aren't Any and aren't dictionaries. When you pass [String: Any] between layers, you're creating a space where anything can get in undetected.
Second, protocols with a single responsibility. A protocol with two methods is easier to defend than one with eight. When it grows to eight, you notice. When it starts with eight, the conversation never happens.
Third, and this is the least obvious one: tests that validate the boundary, not the internal behavior. In a navigation coordinator, the most valuable test isn't checking whether screen A navigates to screen B. It's testing that the coordinator knows nothing about the content of A or B. If it does, the test breaks.
That last type of test is what helped me maintain boundaries over time the most. You write it once and it keeps watch for you.
Honest trade-offs
This approach has a real cost.
Explicit friction slows down delivery in the short term. When the deadline is tight and someone needs a quick path to pass data between modules, a rigid boundary is going to annoy the team. There will be PRs where the discussion is "why can't I just add this field here?"
And sometimes the honest answer is that the architecture is wrong, not the person. Rigid boundaries in the wrong place are worse than flexible boundaries in the right place.
The decision of where to place the friction matters more than placing friction everywhere.
What I would do differently today
In previous projects, I documented the intent of patterns, but I didn't document what the pattern should not accept.
Today I write that out explicitly. Not as a comment in the code, because comments age poorly. I write it as a test or as a compile-time constraint when possible.
If a Coordinator shouldn't know about the internal state of the screens it coordinates, that can become a lint rule, a protocol that hides what shouldn't be visible, or a test that fails if the dependency shows up.
Intent that only exists in the head of whoever wrote the code doesn't survive team turnover.
Final reflection
The code you're going to refactor when you have time is the code that's breaking something.
The code that will stay forever is what works just enough to never be a priority. And that code will grow. It will accumulate layers. It will be read by people who weren't there at the beginning.
The question worth asking before choosing a pattern isn't "does this solve the problem?" It's "will it be obvious when it's being violated?"
Because the problem with bad technical decisions is rarely that they fail. It's that they last.