Image created by AI tool DALL·E 3 — the author has the provenance and copyright.
What is programming? It is a written set of instructions (programs) that a machine will execute. So, we can say that the programmer is the “creator” of the code, but who is the “user” of it? Maybe the machine? No, it is not the machine.
The machine does not understand modern high-level programming languages; in standard cases, programs written in such languages are “translated” to low-level instructions using compilers. The “users” of the code are the other programmers. Programming is a group sport.
Alone, you can write simple or even complex programs, but if you want to create cutting-edge enterprise-level software that you can reliably develop and use, then you need more programmers who will work together in teams.
So, in most cases, many programmers organized in teams actively modify the code throughout the development cycle. Because of that, the modern programming approach is to write your code more for your colleagues, and not so much for the machine. This shift in perspective makes programming more about writing understandable code that should work as expected.
The code should be easy to understand for humans. What is the biggest enemy of understanding? It is complexity. Keeping that in mind, let’s examine some of the famous programming principles/rules that promise to deal with complexity, as follows:
Programming best practices and principles are guidelines and rules that help developers write code that is efficient, readable, maintainable, and scalable. These practices are essential for individual developers and teams to produce high-quality software.
Single Responsibility Principle (SRP): A class should have one, and only one, reason to change.
Open/Closed Principle (OCP): Software entities should be open for extension but closed for modification.
Liskov Substitution Principle (LSP): Objects in a program should be replaceable with instances of their subtypes without altering the correctness of the program.
Interface Segregation Principle (ISP): Many client-specific interfaces are better than one general-purpose interface.
Dependency Inversion Principle (DIP): Depend upon abstractions, not concretions.
These principles sound good on paper, and if used wisely, they can indeed slow down the rate at which complexity grows in your code. Remember, complexity is always your master. In software development, as the project grows and more and more features are added, you can slow the complexity but never stop it.
Most developers learn that the hard way. Based on my practice, such principles/tips/rules should be applied case by case and based on the context.
I have seen many senior programmers applying such practices regardless of the context and case, and the result is adding more complexity to an already complex codebase.
For example, let’s take a closer look at our unnecessarily complicated calculator application.
OverEngineeredCalculator/
│
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ ├── com/
│ │ │ │ ├── overengineeredcalculator/
│ │ │ │ │ ├── calculator/
│ │ │ │ │ │ ├── core/
│ │ │ │ │ │ │ ├── CalculatorEngineInterface.java
│ │ │ │ │ │ │ ├── CalculatorEngineImpl.java
│ │ │ │ │ │ │ ├── OperationStrategyInterface.java
│ │ │ │ │ │ │ ├── OperationFactoryInterface.java
│ │ │ │ │ │ │ ├── OperationFactoryImpl.java
│ │ │ │ │ │ │ ├── CalculatorContextInterface.java
│ │ │ │ │ │ │ ├── CalculatorContextHolder.java
│ │ │ │ │ │ ├── operations/
│ │ │ │ │ │ │ ├── base/
│ │ │ │ │ │ │ │ ├── Operation.java
│ │ │ │ │ │ │ │ ├── OperationImpl.java
│ │ │ │ │ │ │ │ ├── ValidatorInterface.java
│ │ │ │ │ │ │ │ ├── ValidatorImpl.java
│ │ │ │ │ │ │ ├── addition/
│ │ │ │ │ │ │ │ ├── AdditionStrategyInterface.java
│ │ │ │ │ │ │ │ ├── AdditionStrategyImpl.java
│ │ │ │ │ │ │ │ ├── AdditionValidatorInterface.java
│ │ │ │ │ │ │ │ ├── AdditionValidatorImpl.java
│ │ │ │ │ │ │ ├── subtraction/
│ │ │ │ │ │ │ │ ├── SubtractionStrategyInterface.java
│ │ │ │ │ │ │ │ ├── SubtractionStrategyImpl.java
│ │ │ │ │ │ │ │ ├── SubtractionValidatorInterface.java
│ │ │ │ │ │ │ │ ├── SubtractionValidatorImpl.java
│ │ │ │ │ │ │ ├── multiplication/
│ │ │ │ │ │ │ │ ├── MultiplicationStrategyInterface.java
│ │ │ │ │ │ │ │ ├── MultiplicationStrategyImpl.java
│ │ │ │ │ │ │ │ ├── MultiplicationValidatorInterface.java
│ │ │ │ │ │ │ │ ├── MultiplicationValidatorImpl.java
│ │ │ │ │ │ │ ├── division/
│ │ │ │ │ │ │ │ ├── DivisionStrategyInterface.java
│ │ │ │ │ │ │ │ ├── DivisionStrategyImpl.java
│ │ │ │ │ │ │ │ ├── DivisionValidatorInterface.java
│ │ │ │ │ │ │ │ ├── DivisionValidatorImpl.java
│ │ │ │ │ ├── ui/
│ │ │ │ │ │ ├── framework/
│ │ │ │ │ │ │ ├── UIFrameworkInterface.java
│ │ │ │ │ │ │ ├── UIFrameworkImpl.java
│ │ │ │ │ │ │ ├── UIElementFactoryInterface.java
│ │ │ │ │ │ │ ├── UIElementFactoryImpl.java
│ │ │ │ │ │ ├── console/
│ │ │ │ │ │ │ ├── ConsoleUIInterface.java
│ │ │ │ │ │ │ ├── ConsoleUIImpl.java
│ │ │ │ │ │ ├── gui/
│ │ │ │ │ │ │ ├── SwingUIInterface.java
│ │ │ │ │ │ │ ├── SwingUIImpl.java
│ │ │ │ │ │ │ ├── JavaFXUIInterface.java
│ │ │ │ │ │ │ ├── JavaFXUIImpl.java
│ │ │ │ │ │ │ ├── GUIFactoryInterface.java
│ │ │ │ │ │ │ ├── GUIFactoryImpl.java
│ │ │ │ │ ├── utils/
│ │ │ │ │ │ ├── LoggerInterface.java
│ │ │ │ │ │ ├── LoggerImpl.java
│ │ │ │ │ │ ├── ConfigLoaderInterface.java
│ │ │ │ │ │ ├── ConfigLoaderImpl.java
│ │ │ │ │ │ ├── ValidatorInterface.java
│ │ │ │ │ │ ├── ValidatorImpl.java
│ │ │ │ │ ├── exceptions/
│ │ │ │ │ │ ├── CustomExceptionBase.java
│ │ │ │ │ │ ├── InvalidInputException.java
│ │ │ │ │ │ ├── OperationNotSupportedException.java
│ │ │ │ │ ├── integration/
│ │ │ │ │ │ ├── ExternalServiceAdapterInterface.java
│ │ │ │ │ │ ├── ExternalServiceAdapterImpl.java
│ │ │ │ │ │ ├── HistoryRecorderInterface.java
│ │ │ │ │ │ ├── HistoryRecorderImpl.java
│ │ ├── resources/
│ │ │ ├── config.properties
│ │ │ ├── logging.properties
│ ├── test/
│ │ ├── java/
│ │ │ ├── com/
│ │ │ │ ├── overengineeredcalculator/
│ │ │ │ │ ├── calculator/
│ │ │ │ │ │ ├── CalculatorEngineTest.java
│ │ │ │ │ │ ├── OperationFactoryTest.java
│ │ │ │ │ ├── operations/
│ │ │ │ │ │ ├── addition/
│ │ │ │ │ │ │ ├── AdditionStrategyTest.java
│ │ │ │ │ │ ├── subtraction/
│ │ │ │ │ │ │ ├── SubtractionStrategyTest.java
│ │ │ │ │ │ ├── multiplication/
│ │ │ │ │ │ │ ├── MultiplicationStrategyTest.java
│ │ │ │ │ │ ├── division/
│ │ │ │ │ │ │ ├── DivisionStrategyTest.java
This application serves as a prime example of over-engineering, where the application of principles like SRP and ISP, along with an excessive number of interfaces and implementations for simple operations, has led to a bloated structure that obscures the core functionality rather than enhancing it.
To combat complexity effectively:
Keeps the number of project files/modules/packages as small as possible. It’s essential to critically assess the necessity of each component within your project.
Consolidating functionalities where it makes sense can significantly reduce the cognitive load for new developers joining the project.
Think about removing code, not adding more of it.
Embrace refactoring with the aim of simplification. Before adding a new feature or refactoring an existing one, consider the overall impact on the project’s complexity. Sometimes, the best code is the code not written.
Always break the “best practice” if it will make your code simpler.
Context is king. If adhering to a particular principle or practice does not make sense in your specific situation, it’s wise to deviate. The goal should always be to maintain simplicity and clarity, even if it means breaking away from conventional wisdom.
By critically examining the calculator example, we can learn valuable lessons about the pitfalls of over-engineering. Such a mindset will help you eliminate dead code and reward you with a slim and easier-to-understand codebase.
Great software engineers strive for simplicity rather than complexity, understanding that the most elegant solutions are often the most straightforward.
Also published here