When Is Architecture Actually Overkill?
"That's overkill" is one of the most common objections in architecture conversations.
Sometimes it is right.
Sometimes an adapter is just a class that forwards a method with a different name. Sometimes a protocol exists only because every class in the project gets a protocol by default. Sometimes a factory hides a simple initializer behind three files and a vague sense of cleanliness.
That is real. We should not pretend otherwise.
But "overkill" can also become a way to stop thinking. It can turn a concrete design conversation into a feeling. It can mean "I do not like this shape", "I do not want another file", "this is unfamiliar", or "this is not the way we did it in the last project."
In a small app, that feeling may be harmless. In a large app with dozens of engineers and multiple teams, it can become expensive.
The goal is not to win an argument about patterns. The goal is to reason better about cost.
#Overkill Is a Tradeoff, Not a Vibe
Every boundary has a cost.
An adapter costs a file, a name, a test surface, and a little bit of navigation. A protocol costs another concept. A factory costs construction code. A composition root costs discipline.
But direct coupling has a cost too.
It may cost slower tests. It may cost duplicated mapping. It may cost merge conflicts. It may cost five teams having to understand the same third-party SDK. It may cost a ViewModel that knows about networking, analytics, cache policy, and navigation.
So the question is not:
"Does this add a layer?"
The better question is:
"What change does this boundary make cheaper?"
If nobody can answer that, the boundary may be overkill. If the answer is clear, the boundary may be doing useful work.
#A Bad Adapter
Let us start with an adapter that probably does not earn its keep.
protocol ProductsService {
func loadProducts() async throws -> [Product]
}
final class ProductsServiceAdapter: ProductsService {
private let apiClient: APIClient
init(apiClient: APIClient) {
self.apiClient = apiClient
}
func loadProducts() async throws -> [Product] {
try await apiClient.loadProducts()
}
}
This might be fine as a temporary seam, but it is not doing much yet. It does not translate language. It does not hide an unstable API. It does not protect a policy. It does not reduce what the caller needs to know.
It mostly changes a name.
That kind of adapter is easy to criticize, and honestly, fair enough.
The lesson is not "adapters are bad." The lesson is "an adapter should adapt something."
#A Useful Adapter
Now imagine the API returns transport-specific data:
struct ProductsResponse: Decodable {
let items: [ProductDTO]
let experiment: String?
}
struct ProductDTO: Decodable {
let identifier: String
let displayName: String
let amountInCents: Int
let currencyCode: String
}
The UI should not need to know about ProductDTO, cents, experiment fields, backend naming, or decoding details.
An adapter can protect the rest of the app from that.
protocol ProductsRepository {
func products() async throws -> [Product]
}
final class RemoteProductsRepository: ProductsRepository {
private let client: HTTPClient
init(client: HTTPClient) {
self.client = client
}
func products() async throws -> [Product] {
let response: ProductsResponse = try await client.get("/products")
return response.items.map { item in
Product(
id: Product.ID(item.identifier),
name: item.displayName,
price: Money(
cents: item.amountInCents,
currencyCode: item.currencyCode
)
)
}
}
}
This adapter earns its keep because it prevents transport details from leaking into the feature.
If the backend changes displayName to title, you change the adapter. If the app changes how it models money, you change the adapter. If previews need in-memory products, they can use another repository.
The extra type is not ceremony. It is a boundary around change.
#"But Then the App Is Full of Adapters"
Maybe. But full of which adapters?
An app full of adapters that only rename methods is painful.
An app full of adapters that protect feature code from SDKs, APIs, persistence details, analytics tools, experiments, and team ownership boundaries can be much healthier than an app full of direct calls.
The number of files is not the most important metric.
In a large codebase, I care more about:
- how many teams need to coordinate for one change
- how far third-party details spread
- how hard it is to test a feature
- how many places duplicate the same mapping
- how often unrelated files change together
- how confidently a team can replace one implementation
An adapter that only changes names is ceremony.
An adapter that prevents one team's change from leaking into five other teams is architecture.
#Small App Advice Does Not Always Scale
In a small app, direct dependencies can be perfectly reasonable.
struct ProductsFeatureAdapter {
let client: HTTPClient
}
If there is one team, one app, one API, and one obvious way to fetch products, adding multiple boundaries may slow everyone down.
But large apps are different.
Large apps tend to have:
- multiple teams changing nearby code
- shared modules with unclear ownership
- long-lived features
- A/B tests
- migrations between APIs
- multiple persistence strategies
- third-party SDK churn
- different release pressures across teams
In that environment, direct coupling is not free. It just sends the bill later.
The mistake is using small-app instincts to judge big-app problems.
#The "Overkill" Checklist
Before calling something overkill, try asking these questions:
- What change does this boundary protect?
- Is that change likely in this codebase?
- Who owns each side of the boundary?
- Does this hide a real detail, or only rename it?
- Does this make tests simpler or more meaningful?
- Does this reduce what the caller needs to know?
- Does it help us compose behavior like logging, caching, analytics, retries, or experiments?
- Will this still make sense to a new engineer in six months?
If the answers are weak, remove the abstraction.
That is not failure. That is good design.
If the answers are strong, "overkill" is probably the wrong word. The boundary is buying something.
#An Example With Analytics
Suppose a feature loads products and we need to track success and failure.
The direct version is tempting:
final class ProductsViewModel: ObservableObject {
private let repository: ProductsRepository
private let analytics: AnalyticsTracking
func load() async {
do {
let products = try await repository.products()
analytics.track("products_loaded")
// update state
} catch {
analytics.track("products_failed")
// update state
}
}
}
It is not horrible. Many apps have code like this.
But now the ViewModel knows about product loading, analytics, failure policy, and state mapping. If another feature needs the same tracking behavior, we may duplicate it. If analytics changes, presentation code changes. If the team wants to test state mapping, analytics comes along for the ride.
A composed version puts analytics at the boundary:
final class AnalyticsProductsOutputDecorator: ProductsOutput {
private let decoratee: ProductsOutput
private let analytics: AnalyticsTracking
init(decoratee: ProductsOutput, analytics: AnalyticsTracking) {
self.decoratee = decoratee
self.analytics = analytics
}
func didLoadProducts(_ products: [Product]) {
analytics.track("products_loaded")
decoratee.didLoadProducts(products)
}
func didFailLoadingProducts() {
analytics.track("products_failed")
decoratee.didFailLoadingProducts()
}
}
Now the ViewModel maps outputs into state. The decorator adds analytics. The use case decides which output to send. The composition root wires them together.
Is that more code? Yes.
Is it overkill? It depends.
If one screen does this once, maybe. If ten features need consistent tracking and three teams touch them, the boundary can easily pay for itself.
#The Hidden Cost of "Simple"
"Simple" often means "easy to see right now."
That is a good kind of simple, but not the only kind.
There is also operational simplicity:
- simple to test
- simple to replace
- simple to review
- simple to split between teams
- simple to delete
- simple to migrate
Direct code often wins the first kind of simplicity. Boundaries often win the second kind.
The trick is knowing which kind of simplicity the codebase needs.
#Be Kind in These Conversations
Most developers who say "this is overkill" are not trying to ruin the architecture.
They may have seen bad abstractions. They may have worked in a codebase where every simple change required opening eight files. They may be protecting the team from ceremony. That instinct is valuable.
The best response is not "you do not understand architecture."
A better response is:
"I agree that extra boundaries have a cost. The reason I think this one is useful is that it protects us from this specific change."
Or:
"You might be right. If we cannot name the change this adapter protects, we should probably remove it."
That turns the conversation from taste into reasoning.
#What You Should Remember
Architecture is not about maximizing adapters, protocols, or layers.
It is about placing boundaries where they reduce the cost of likely change.
Sometimes the simplest thing is a direct call. Sometimes the simplest thing is an adapter that keeps a third-party SDK, backend model, or cross-team decision from spreading through the app.
Overkill is not measured by the number of files. It is measured by whether the extra boundary costs more than the coupling it removes.
Be skeptical of ceremony. Be skeptical of hidden coupling too.
Read next
The Open-Closed Principle in Swift: Add Behavior Without Breaking Existing Code
Learn the Open-Closed Principle in Swift with practical examples using protocols, composition, strategies, and decorators.