The Problem With Over-Engineering Swift Architectures
By Yuki Tanaka
Every iOS developer remembers the first time they heard about VIPER. Or Clean Swift. Or some new coordinator pattern. The promise was always the same: clean separation, testability, scalability. But after a decade of building apps — everything from tiny utilities to sprawling financial platforms — I’ve tripped over the same failure mode again and again. It’s not a missing architecture. It’s too much of one.
This piece walks through the specific costs of over-engineered Swift architectures. It draws from real projects, failed refactors, and those quiet moments when a team finally admits that the architecture is harder to maintain than the business logic it was supposed to organize. I’m not here to trash patterns. I want to give you a way to spot the moment they start working against you.

Signs You Are Over-Engineering
Before you can fix over-engineering, you have to see it. The symptoms are sneaky because they hide behind good intentions — “future-proofing,” “best practices.” Here are the concrete tells I look for in a codebase now.
1. Protocol Explosion with Single Implementations
Swift’s protocol-oriented design is genuinely useful. But crack open an over-engineered project and you’ll find a Services group with a dozen protocols, each backed by exactly one class. UserServiceProtocol, AuthServiceProtocol, AnalyticsServiceProtocol. The justification is usually “testability” or “we might swap the implementation later.”
In practice, that swap almost never happens. And when it does, the new implementation rarely fits the old protocol without surgery. The protocol fossilizes a past assumption. Meanwhile, every new developer has to trace through the protocol to find the concrete type, adding cognitive weight for no runtime gain. If there’s only one implementation, question the protocol. A concrete type with a clearly defined interface is often cleaner and easier to test with a simple mock subclass.
2. The Use Case Layer That Does Nothing
Clean Architecture preaches use cases (or interactors) that hold business logic. The theory is reasonable: decouple the UI from data operations. But I’ve audited projects where every use case is a one-liner that calls a repository and hands back a result.
A typical example:
class FetchUserUseCase {
private let repository: UserRepositoryProtocol
init(repository: UserRepositoryProtocol) {
self.repository = repository
}
func execute(userID: String) async throws -> User {
return try await repository.fetchUser(id: userID)
}
}
This class contains zero business logic. It is a wrapper around a repository call. All it achieves is adding a file, a protocol, and a dependency injection step. If your use case is a pass-through, delete it. Let the view model or presenter call the repository directly. You can always pull out a use case later when actual conditional logic, validation, or orchestration shows up. Premature abstraction is the root of this particular waste.
3. View Models That Mirror the Model
Another pattern I see constantly: a view model that maps model properties one-to-one with zero transformation. The User model has firstName and lastName. The UserViewModel has firstName and lastName. The view model’s only job is to hold the same data inside @Published properties.
This duplication is a maintenance tax. Add a new field to the model, and you must add it to the view model, the mapper, and the tests. If the mapping is trivial, think about exposing the model straight to the view, maybe wrapped in a bare @Observable class if you’re targeting iOS 17. View models should earn their place by combining, formatting, or deriving data. A view model that’s a carbon copy of the model is just noise.

The True Cost of Over-Engineering
It’s tempting to brush off over-engineering as a harmless excess, like leaving too many comments. But it has measurable, negative effects on a project’s health. These costs compound over time and almost never show up in planning meetings.
Slower Feature Development
When every new screen demands a view, a view model, an interactor, a presenter, a router, and a builder, the overhead per feature gets heavy. I once timed a simple settings screen in a VIPER project: 8 files, 12 protocols, and roughly 45 minutes of boilerplate before I wrote a single line of actual logic. The same screen in a pragmatic MVVM setup took 3 files and 10 minutes.
Multiply that across a team of five over a year. The hours lost to boilerplate are hours not spent on things users will ever notice. Over-engineering doesn’t just slow the initial build; it slows every future change because each layer must be updated in lockstep.
Onboarding Friction
New team members hit a steep learning curve in over-architected codebases. They have to learn not only the business domain but also a custom set of conventions, protocols, and wiring rules. I watched a junior developer burn two days tracing a simple data flow through coordinators, routers, and delegates — just to change a button label.
This friction shrinks the bus factor and makes the team dependent on whoever designed the original mess. Documentation rarely saves you because the real architecture drifts from its original blueprint within a few sprints. The system turns into tribal knowledge, and every departure is a knowledge loss.
Test Suite Fragility
Over-engineering often hides behind the shield of testability. But the resulting test suite can become its own liability. If you have a protocol for every dependency, you’ll have a mock for every protocol. Those mocks must be maintained in parallel with the real implementations. When a method signature changes, you update the protocol, the real class, the mock, and every test that touches that mock.
I worked on a project with 60% test coverage that caught fewer bugs than a simpler project with 30% coverage. The difference? The simpler project’s tests focused on business logic. The over-engineered project’s tests mostly verified that mocks were called in the right order. That’s testing the architecture, not the product. A well-placed integration test or a handful of unit tests on a concrete service can be worth far more than a thousand mock-verification checks.
A Pragmatic Approach to Swift Architecture
So how do you dodge this trap without sliding into a “view controller does everything” mess? The answer is to let architecture emerge from concrete needs instead of imposing it upfront. Here are the principles I follow now.
Start with MVC, Then Extract
Apple’s Model-View-Controller gets mocked as “Massive View Controller.” But the pattern itself isn’t the problem; undisciplined developers are. A well-written MVC screen can be perfectly testable: the view controller handles user input and view updates, while the model layer holds all business logic and networking.
I start a new feature with a single view controller and a single model object. I write it so the model can be instantiated and tested without any UIKit imports. As the feature grows, I watch for specific pain points. Is the view controller doing too much formatting? Extract a view model. Is there complex flow logic? Add a coordinator. Let the complexity pull the architecture, not the other way around.
Favor Concrete Types Over Protocols
Swift’s type system is expressive enough to support testing without a blanket of protocol abstraction. For a networking layer, you don’t need APIClientProtocol. You can create a concrete APIClient that takes a URLSession injected at init. In tests, use a URLProtocol subclass to stub responses. The public interface stays stable, the implementation is swappable, and there’s no protocol to maintain.
When a protocol is genuinely needed — for polymorphism or to decouple a module boundary — it should be small and focused. A protocol with one method is often a sign you could just pass a closure. A protocol with ten methods is a sign you’ve abstracted at the wrong level. Aim for protocols that represent a single, stable concept, not a grab bag of related functions.
Delete Code Aggressively
The most direct way to fight over-engineering is to treat code as a liability. Every line you write is a line you must read, debug, and update. After every feature, I ask: can I delete anything? Did that use case end up as a pass-through? Remove it. Is that builder only used once? Inline it.
I’ve made it a habit to spend the last hour of a feature branch on deletion. I open the project navigator, stare at every new file, and ask if it pulls its weight. This practice keeps the codebase lean and stops architectural cruft from piling up. The best architecture is the one that isn’t there.

When Complexity Is Justified
I’m not arguing against all architecture. Some domains genuinely need complex patterns. The trick is to tell essential complexity from accidental complexity.
Essential complexity comes from the business domain. If you’re building a trading app with real-time price updates, multiple order types, and regulatory requirements, you’ll have complex state management. A unidirectional data flow with a reducer pattern might be the clearest way to express that logic. The architecture is a response to a real problem.
Accidental complexity comes from the architecture itself. It’s the protocols, layers, and indirections that exist because a blog post or conference talk said they should. This complexity is optional and often harmful.
Before you adopt any pattern, ask: does this solve a problem I actually have, or a problem I might have someday? If the answer is “someday,” wait. The cost of adding architecture later is usually lower than the cost of maintaining unnecessary architecture now.
FAQ
How do I convince my team to simplify an over-engineered codebase?
Start with numbers. Track the time spent per feature and the number of files touched for a simple change. Show that data alongside a concrete proposal for a simpler alternative in one module. A small, successful refactor is far more persuasive than a philosophical argument. Show, don’t just tell.
Isn’t it risky to delete protocols and layers? What if we need them later?
It’s less risky than you think. Version control gives you a permanent record of the deleted code. If a future feature genuinely needs that abstraction, you can recover it and adapt it to the actual requirements. In practice, resurrected code is rare, and the clarity you gain by deleting is immediate.
What’s the simplest architecture I can use that still allows for testing?
A concrete model layer with dependency injection through initializers. Your view controller receives a model object (or a service object) that contains all business logic and networking. In tests, instantiate the model with a custom URLSession or a stub database. This approach needs no protocols beyond what Swift’s standard library provides and is trivially testable.
How do I know if I’m under-engineering instead?
Under-engineering usually shows up as large, untestable classes with mixed responsibilities. If your view controller is making network calls, parsing JSON, and updating UserDefaults, that’s under-engineering. The fix is to pull those responsibilities into separate, focused objects. The goal is separation of concerns, not separation of layers.
Conclusion
Over-engineering is a seductive trap because it feels like progress. You’re creating abstractions, following patterns, building for the future. But software isn’t a bridge; it doesn’t need a safety factor of ten. The best Swift architectures are humble. They solve today’s problems with the simplest possible design and leave room for tomorrow’s changes without pretending to predict them.
Next time you reach for a protocol, a use case, or a coordinator, pause. Ask if the complexity is essential or accidental. Your future teammates will thank you, and your app will ship faster. That’s the real measure of good architecture: not how many patterns it uses, but how little it gets in the way.