Opening
Abstraction is the most celebrated tool in software engineering. We are taught from day one: do not repeat yourself, extract common behavior, program to an interface. And look, abstraction is genuinely powerful. Without it, we could not build complex software. But here is something I wish someone had told me ten years ago: abstraction is also the most overused tool in software engineering. Every abstraction has a cost. Indirection. Naming. Cognitive load. And premature abstraction — abstracting before you understand the problem — is arguably worse than premature optimization. Today I want to give you permission to write concrete code, and more importantly, I want to give you a framework for knowing when abstraction helps and when it hurts.
Every Abstraction Has a Cost
Let me start with the costs, because we rarely talk about them. The first cost is indirection. When you hide implementation behind an interface, anyone reading the code has to jump from the call site to the interface, then find the implementation. Instead of reading the code linearly, you are jumping around the codebase. One level of indirection is fine. Three or four levels deep and you are spending more time navigating than understanding. The second cost is naming. Every abstraction requires a name, and naming is genuinely one of the hardest problems in software. DataProcessor. RequestHandler. ServiceManager. These names tell you nothing. They exist because someone needed to abstract something but could not find a name that described what the abstraction actually does. Bad names are a sign that the abstraction does not have a clear purpose. The third cost is cognitive load. The reader of your code now has to understand not just what happens, but how the abstraction works, what its contract is, and what behaviors are hidden behind it.
The Repository Pattern for a Single API Call
Let me give you a concrete example that I see constantly. You have one screen that calls one API endpoint. A product detail screen that calls GET /products/{id}. The tutorial tells you to create a ProductRepository interface, a ProductRepositoryImpl, a ProductRemoteDataSource, and maybe a ProductLocalDataSource for caching. That is four classes for one API call. And the repository implementation is literally one line: return dataSource.getProduct(id). What did the abstraction buy you? Theoretically, you can swap data sources. Practically, you never will. Theoretically, you can test the ViewModel in isolation. Practically, you can inject a fake Retrofit service directly and get the same benefit. The abstraction did not simplify anything — it just distributed one line of logic across four files. What should you do instead? Have the ViewModel call the API service directly. One class, one dependency, zero indirection. When your data needs grow — maybe you add caching, maybe you add a second data source — extract the repository then. Not before.
The Use Case with One Method Call
Another pattern I see everywhere: a use case class that delegates to a single repository method. GetProductUseCase, its invoke function calls repository.getProduct. That is the entire class. The argument is always "but what if we need to add business logic later?" And my answer is: what if you do not? If you add it now with no logic, you have paid the cost of an extra class, an extra file, an extra injection, an extra layer to navigate through. If and when you need it, extracting a use case from a ViewModel takes about sixty seconds. You are paying a definite cost today to avoid a potential cost tomorrow. That is speculation, not engineering. Write the simplest code that solves the current problem. Refactor when the problem changes.
Interfaces with One Implementation
This is perhaps the most common example of premature abstraction. An interface with exactly one implementation, where no second implementation is planned, expected, or even plausible. UserRepository and UserRepositoryImpl. NetworkClient and NetworkClientImpl. AnalyticsTracker and AnalyticsTrackerImpl. Every time. One interface, one implementation. The "Impl" suffix is a code smell that tells you this abstraction has no semantic meaning. When you have two implementations — say, a RealAnalyticsTracker and a DebugAnalyticsTracker — the interface has genuine value. The name of each implementation describes its purpose. But when there is only one, the interface adds navigation friction, doubles your file count, and makes the IDE's "go to definition" less useful. Use a concrete class. If you later need a second implementation, your IDE can extract an interface in seconds.
The YAGNI Principle
YAGNI stands for "You Aren't Gonna Need It." It is one of the most important principles in software engineering, and one of the most ignored. The idea is simple: do not build something until you actually need it. Do not add a caching layer because you might need caching someday. Do not create a plugin architecture because you might want plugins someday. Do not abstract your database access because you might switch databases someday. Each of these "might" scenarios has a probability. Usually, that probability is low. And when you build for a scenario that never happens, you have not just wasted time building it — you have permanently increased the complexity that every future developer has to understand and maintain. YAGNI is not about being lazy. It is about being honest with yourself about what you actually know versus what you are guessing.
The Rule of Three
So when should you abstract? I follow a simple rule that I learned early in my career and it has served me incredibly well. It is called the Rule of Three. The first time you write a piece of code, just write it. Concrete, specific, right there where you need it. The second time you need similar code, wince a little, but write it again. Maybe copy and paste, maybe rewrite. Resist the urge to abstract. The third time you need it, now you have three concrete examples of the pattern. Now you understand the commonalities and the differences. Now you can write an abstraction that actually captures the right pattern, because you have seen three real cases. Abstracting from one example is guessing. Abstracting from two examples is risky — maybe the two cases just happen to look similar but diverge later. Abstracting from three examples is engineering — you have enough data to make a good decision.
Premature Abstraction vs. Premature Optimization
You have probably heard that premature optimization is the root of all evil. Donald Knuth said that — or rather, he said premature optimization of specific code sections is usually a mistake. But I would argue that premature abstraction is at least as dangerous. Here is why. When you prematurely optimize, you make code harder to read but it still works. You can always remove the optimization later and the code still functions. When you prematurely abstract, you shape your entire codebase around an assumption that might be wrong. The abstraction becomes load-bearing. Other code depends on it. Removing it requires restructuring, not just deleting a few optimization tricks. A wrong abstraction is like building a house on the wrong foundation. You cannot just swap it out. You have to rebuild everything above it.
Signs You Have Over-Abstracted
How do you know if you have over-abstracted? Here are the warning signs. First: you have classes whose name ends in Manager, Handler, Processor, or Service and you cannot explain what they do in one sentence without saying "it manages" or "it handles." Second: navigating from a user action to the code that executes it requires jumping through more than three files. Third: new team members consistently struggle to understand the codebase despite being experienced developers. Fourth: making a simple feature change requires touching five or more files across multiple layers. Fifth: your interfaces have exactly one implementation and no realistic prospect of a second. If three or more of these are true, you have likely over-abstracted.
Write Concrete Code First
Here is my practical advice. When starting a new feature, write the most concrete, straightforward code you can. ViewModel calls repository. Repository calls API. Data comes back, state updates, UI renders. No interfaces unless you genuinely need polymorphism. No use cases unless you genuinely have business logic. No wrapper classes unless you genuinely need to adapt something. Get it working. Ship it. Watch how it evolves over the next few sprints. If certain patterns recur, abstract them. If the code stays simple, leave it simple. Your future self will thank you for code they can read in five minutes rather than code they have to trace through fifteen files. Simple code is not unprofessional. Over-engineered code is not sophisticated. The best code is the simplest code that solves the problem and communicates its intent clearly.
When Abstraction IS Right
I do not want to leave you thinking abstraction is always wrong. It is a matter of timing and evidence. Abstract when you have multiple concrete implementations that need a common interface. Abstract when you have a genuine architectural boundary that different teams work on. Abstract when you need to isolate a dependency for testing and mocking concrete classes is insufficient. Abstract when you have seen the same pattern three times and you understand the invariants. And abstract when the cost of duplication genuinely exceeds the cost of the indirection. These are evidence-based reasons. They come from observing your codebase, not from reading a theory.
Closing
Here is what I want you to remember. Every abstraction is a trade-off between reusability and readability, between flexibility and simplicity. The cost of a wrong abstraction is higher than the cost of duplicated code, because you can always extract an abstraction later, but unwinding a wrong one is painful. Write concrete code first. Abstract when you have evidence. Follow the Rule of Three. Apply YAGNI honestly. And never feel guilty about simple, straightforward code. It is not a sign of inexperience — it is a sign of wisdom. That wraps up our phase on architecture trade-offs. Next, we are moving into Phase 7 — Performance. And we are going to start with the most important performance lesson of all: where time is actually spent, and why most optimization effort is wasted.