Hello people!
Lately I’ve been seeing a lot of conflicting advice about writing clean code. On one hand, people say “Don’t over-engineer, keep it simple!” and on the other hand, “Follow all the SOLID principles and design patterns!”.
It can be confusing to figure out the right approach. In this article, I want to clear up these contradictions and share a balanced strategy I use called CLEAR. It’s a set of principles that helps me write code that is easy to maintain and easy to test, without unnecessary complexity.
Let’s dive in!
Avoid Over-Engineering (KISS & YAGNI)
First, let’s address the urge to over-engineer. You might have heard of KISS (Keep It Simple, Stupid), which basically means don’t add complexity that isn’t needed. This goes hand-in-hand with YAGNI (You Aren’t Gonna Need It), a reminder to only build what you need right now.
In short: Don’t write code for hypothetical future requirements. Over-engineering violates these principles by introducing layers, abstractions, or “clever” solutions with no clear benefit. The result? Code that’s harder to understand and maintain, with no real payoff.
Resist the temptation to add every pattern or abstraction by default. For example, some developers create an interface for every class “just in case” they might need it in the future. In reality, doing that everywhere is overkill.
By adding unnecessary indirections and layers, you make the codebase more verbose and complex without any actual gain.
Instead, keep things simple.
Apply patterns judiciously , only where they add concrete value. If a class is unlikely to ever have multiple implementations, or if it’s just a plain data holder, creating an interface for it provides no advantage. It’s like using a bazooka to kill a fly, overkill!
You can always introduce an abstraction later when a real need arises (that’s the essence of YAGNI). The goal is to fulfill today’s requirements with lean, straightforward code and no extra baggage.
Remember: do the simplest thing that works, and only abstract or extend when you truly need to.
Use Interfaces (and Abstraction) Judiciously
So, how do we reconcile the “keep it simple” advice with the idea that we should “code to interfaces” or use abstraction?
The key is context.
Not every part of your system needs an abstraction, but some parts definitely do benefit from it. Interfaces and abstract classes are great tools for decoupling modules and providing flexibility, but you should apply them in the right places.
But Julio, where interfaces shine?
The answer is: In application layers like services or repositories, using an interface to define the contract can be very useful. These are parts of the code that may have different implementations (e.g. swapping databases or using a mock for testing) or that you might want to decorate (for example, adding caching or logging around a service).
In such cases, coding to an interface decouples what is done from how it’s done, making future changes easier.
In fact, core layers like complex services and persistence classes should expose an interface for exactly this reason. This lets you introduce alternative implementations (like a fake repository for tests, or a new data source) without altering the code that uses them.
It also enables advanced patterns like Aspect-Oriented Programming (AOP) proxies or decorators (heart of Spring Boot), if you want to wrap a service with logging, having it implement an interface makes that plug-and-play.
Ok, I got, so tell me now where interfaces hurt more than help.
Of course, so, there we go!
For simple data-carrying objects or value objects, an interface is usually pointless. A data entity like User or a value object like Coordinates typically has no behaviour that varies; it’s just data (maybe with basic validation).
Creating IUser or ICoordinates adds indirection with no gain, since you will never have multiple “implementations” of what is essentially a data structure.
In these cases, an interface doesn’t make the code more flexible or testable, it only adds noise and extra files to manage.
You can just instantiate and use the object directly in both production and tests.
So in other words, model/value objects (like Person or Address classes) should not have interfaces. It simply doesn’t buy you anything.
So, you must find the right balance, use interfaces (or abstract classes) for the parts of your system that are likely to change or benefit from multiple implementations, and keep the rest of the code simple and concrete.
There is no one-size-fits-all rule, the gray areas require good judgement. We shouldn’t fall into dogma like “never use interfaces” or “always use interfaces everywhere.” Instead, apply abstraction where it genuinely makes your design cleaner or more adaptable, and avoid it where it only adds clutter.
This balanced approach eliminates the apparent contradiction: we’re not advocating two opposite extremes, but rather a cohesive guideline.
In summary: don’t over-engineer with needless abstractions, but do introduce interfaces when they truly add value.
The CLEAR Approach to Clean Code
To help keep my code maintainable and coherent, I follow a set of design guidelines summed up by the acronym CLEAR. This stands for:
- C — Clear Responsibilities: Each module or class should have a well-defined purpose and do one thing well (much like the Single Responsibility Principle). When every component has a focused job, the code becomes easier to understand and change. Nothing feels “muddy.” If you notice a class or function doing too much, consider splitting it up until each part has a clear role. Keeping responsibilities clear cuts down on confusion and makes it obvious where to make changes when requirements evolve. (Ask yourself: does this piece of code have one job? If not, it’s probably time to refactor.)
- L — Low Coupling: Strive to minimize how much different parts of your code know about each other. In a loosely coupled system, a change in one component has minimal impact on others. You can achieve low coupling by designing clean interfaces between modules and using techniques like dependency injection (passing in a dependency rather than hard-coding it). The less your classes depend on the concrete details of others, the more flexible and testable your code is. In short, components should depend on as little context as necessary to do their job.
- E — Encapsulate What Changes: Identify the aspects of your code that are likely to change or vary (business rules, external API calls, file/database access, etc.) and encapsulate them behind well-defined boundaries. In practice, this means hiding the internal details behind an interface or separate module so the rest of the codebase doesn’t need to worry about them. By isolating these volatile parts, the overall system becomes more stable, and if something changes, you only update that one module. For example, if you suspect that a certain piece of logic might need a different algorithm for different scenarios, or that you might switch out your data storage technology, put that logic behind an interface or in its own class. This principle is related to the Open/Closed Principle (open to extension, closed to modification): you prepare extension points only where needed. Encapsulating what varies keeps the codebase clean because each part can evolve independently, and changes don’t ripple through the entire system.
- A — Abstract Dependencies: Code against abstractions for the important seams in your system. This is a result of low coupling. In practice, key services or components should define an interface or abstract class for their behaviour, with concrete classes implementing it. By introducing an abstraction at the right places (say, between your business logic and an external service or database), you decouple the interface of what something does from the implementation. This gives you flexibility: you can swap out implementations without changing the code that depends on them. This is essentially the Dependency Inversion Principle from SOLID. A common example: if you have a
DataStoreclass that hits a database, define anIDataStoreinterface. Your core logic can depend onIDataStore, and you can have one implementation for a real database and another for an in-memory or mock database for tests. (Important caveat: as discussed earlier, only do this where it makes sense. Focus on abstracting meaningful dependencies that might change or need swapping. Don’t introduce an interface for something that will only ever have one implementation.) - R — Reuse (Don’t Repeat Yourself): Avoid duplicating code or logic. If you notice the same chunk of code (or the same functionality) in multiple places, refactor and consolidate it into a single function or module that can be reused. This is the classic DRY principle. By having a single source of truth for each piece of logic, you reduce bugs (fixing a bug in one place fixes it everywhere) and make the code easier to maintain. Reusability also means writing code that is generalized enough to work in different contexts without being needlessly complex. For example, if two services need similar validation logic, it might make sense to extract a common helper function or class for that validation, rather than writing it twice. Be careful to strike a balance: make things reusable when you actually see a need or duplication (remember YAGNI, don’t build an elaborate generic utility for a one-time problem). When done appropriately, DRY code results in a cleaner, more coherent codebase where each piece of knowledge lives in one place.
These CLEAR principles complement each other. If each class has clear responsibilities and you keep coupling low, it naturally sets you up to achieve more reuse (because modules are independent and focused, so you can mix-and-match or share them easily). Encapsulating changes and abstracting dependencies go hand-in-hand to create a codebase that’s flexible but not fragile. The overarching idea is consistency: you get a unified, intentional approach to design rather than a bunch of ad-hoc decisions. By keeping your code CLEAR, you prevent the scenario where one part of the code says “do X” and another part contradicts it by doing “Y” differently. Instead, everything follows the same guiding principles, making the whole system easier to understand and evolve.
How CLEAR Leads to Easier Unit Testing
One big bonus of writing code in a CLEAR way is that it becomes much easier to unit test. Clean and decoupled design means you can test components in isolation, without a lot of setup or special tweaking. Here are a few ways the CLEAR principles make your code more test-friendly:
- Focused, Single-Purpose Components (C): When classes and functions have clear, single responsibilities, it’s straightforward to write targeted unit tests for each piece of functionality. You’re testing one thing at a time, with fewer dependencies to stub out. Smaller units of code mean simpler test scenarios. For example, if you have a function whose only job is to calculate a result given some inputs, you can call it with different inputs and assert outputs easily. There’s no complex context to worry about.
- Loose Coupling via Dependency Injection (L) and Abstracted Dependencies (A): Because your code is loosely coupled and depends on interfaces (or abstractions) for its collaborators, you can easily substitute real dependencies with fake ones in your tests. This is a game-changer for unit testing. Suppose you have a service class that talks to a database through an
IDataStoreinterface, in a test, you can provide a dummy implementation ofIDataStorethat returns controlled, predictable data. This way, your tests run fast (no actual database needed) and you can simulate various scenarios (like errors or edge cases) by tweaking the fake. In fact, using interfaces for external interactions is a common technique to enable mocking.
Following CLEAR naturally encourages this style because we abstract our key dependencies. The result: each unit test can inject stubs or mocks for any external service and focus purely on the logic of the unit under test. - Encapsulation of Volatile Details (E): By encapsulating the parts of code that talk to external systems or are likely to change, your core business logic is shielded from those details. This also means your tests for the core logic don’t need to deal with those external parts. For instance, if you wrap an external API call in its own class or interface, your other code (and its tests) can use a fake implementation of that API wrapper. The tests won’t fail just because the external API changed or is down, you’ve isolated that concern. This makes your unit tests more reliable and easier to write. Essentially, each encapsulated part can be tested on its own, or replaced with a stub in higher-level tests.
- DRY and Reuse (R) = Less Repetition in Tests: When you avoid duplicating logic in your code, you also avoid duplicating test logic. Instead of writing similar tests for the same behaviour in multiple places, you write one set of tests for the one implementation of that behaviour. This reduces the amount of test code and maintenance effort. Moreover, a consistent, modular codebase means you can often follow a consistent approach in writing tests. Once you have a pattern for testing a certain type of component (say, testing a controller with a mocked service layer), you can reuse that pattern across many tests. You’re not reinventing the wheel each time, because your code structure is predictable. This consistency makes the whole test suite easier to understand and maintain.
Overall, designing with CLEAR principles leads to a codebase where each piece is independent and predictable, which is exactly what you want for unit testing. You can instantiate a class, inject a couple of fake dependencies, call a method, and assert the result, without needing a whole application context. The tests stay fast, focused, and easy to debug when something fails. When writing tests is straightforward, you’re more likely to write them for all your code, meaning you catch bugs early and can refactor with confidence. In short, clean design == easier testing, and easier testing means better software quality.
Conclusion
Great software design is a balancing act. We want our code to be simple and clear, but also flexible and robust to future changes. The apparent contradiction between “don’t use interfaces everywhere” and “you should code to interfaces” disappears when you apply contextual thinking. Use the right tools in the right situations. The CLEAR principles help navigate this balance by emphasizing clarity, appropriate abstraction, and modularity without veering into over-engineering. By keeping your code lean, focused, and well-structured, you make it easier for yourself (and others) to understand and modify it down the road. And by selectively applying interfaces and other abstractions where they add value (like allowing multiple implementations), you ensure your code is prepared for growth and change when it comes.
At the end of the day, writing clean, testable code comes down to being deliberate.
Don’t follow every rule or pattern blindly, understand the why behind a principle and use it when it genuinely improves your software.
Keep your codebase CLEAR, and you’ll find it stays coherent, easy to maintain, and ready for whatever the future throws at it.
Happy coding!
References
- “You aren’t gonna need it” (YAGNI) — Extreme Programming principle. Wikipedia.
- Jason Gilmore (Aug 22, 2023). What is Loose Coupling in REST APIs? — DreamFactory Blog.
Comments
No comments yet. Be the first.