When you start reading about some topic or adopting the practice, It is crucial to ask yourself, "Why?". Why do I need it? Why is it essential for me? So let's start with why here. Why do we need to speed up the development? The answer is petty simple - to bring value to our users faster. Until our application gets to the users and starts serving their needs, it brings zero value and is literally useless.
When you hear about reusability, you will probably remember the DRY principle. DRY stands for "don't repeat yourself," saying that we should avoid code duplication and need to try to reuse as much code as possible. So must not write the same code twice. Instead, reuse what was already written. DRY is a pretty straightforward principle and sounds logical. It seems like that principle applies to all cases. Despite its simplicity, you should use it carefully in your application's code base. It hides some implicit drawbacks behind its simplicity, and it takes time to reveal them. And those drawbacks could result in significant development time losses, which will slow down our development. Quite the opposite to our initial goal, right?
First and foremost, be careful applying the DRY principle to significant chunks of the functionality and reusing them across different features. Those features code could be similar or even the same now, and extracting the reusable code could seem like a great idea. It will result in having less code and, therefore, fewer bugs. But you will create an implicit coupling of those two features. So despite that, currently, all is good and fine.
The further the interesting it gets. After several iterations on our features, they could start evolving in entirely different directions. It will be an incremental evolution for sure. And slowly, our reusable code is getting newer and newer 'if' statements inside, and along with them, the feature's specific abstractions start leaking into the reusable code. Remember the coupling we have created? After a while, we could find ourselves desperately trying to glue two completely different functionalities together. That code will have tons of branching statements ('if', 'switch-case,' etc.) and the unrelated abstractions holding all that mess on their shoulders. Once, they will shrug, but we don't see it now. We need one more bug fix, and all will be perfect.
Meanwhile, the code is becoming a single point of failure for both features, and with each new line, it is getting more fragile and sensible for any changes. Unfortunately, such code is hard to read, maintain, and extend. Additionally, it spawns an enormous amount of communication between the feature owners, required to devise how to squeeze another piece of the code inside our Frankenstein, let alone refactoring and bug fixes. You need to get familiar with two feature codebases to be able to change something safely. After some time, we end up with a situation where small change requires tremendous effort to implement and even more to test. And that is the dead-end the code needs to be completely refactored.
I described the exaggerated case, for sure. The intention was to make the issues more prominent. The right question is, "How to avoid such situations? Should we duplicate all the code and avoid code reuse like the plague?" No, for sure not. The part I intended to highlight in the example was the size of the code we were trying to reuse. So it probably was a significant chunk of the common functionality that evolved differently for the different functionalities.
If the functionalities seem similar now, they could evolve in entirely different directions after several iterations.
Being aware of the evolutionary nature of modern applications is crucial. That awareness will make you more sensitive to such situations, and you will spot them at an early stage.
The general recommendation at any stage would be to extract and reuse the smallest possible blocks - your application's units.
Code unit could be class, function, method, etc.; the smaller the code block is, the easier it is to reuse.
Having numerous small building blocks, we can compose them in various ways, crafting a unique processing flow for each particular use case without creating a tight coupling between any of those. Building blocks we need to keep simple and sweet.
Numerous software development principles can help us keep that simplicity and sweetness - SOLID, GRASP, OOP, FP, etc. The most neat thing is that those principles are much easier to apply to small code parts rather than their big chunks.
Is that idea a silver bullet? No, not at all. That approach has several pros and cons, and you should carefully evaluate them before applying them to your project.
PROS:
- Improves code reusability.
- Improves code testability (unit tests are the easiest to adopt in general and especially for the small code units)
- Improves separation of concerns,
- Improves code locality
- Improves code readability, and it is getting easier to reason about it.
- Identifying the violation of software development principles and best practices and "bad code smells" is getting easier.
- Improves code composability; variously composing small building blocks, we achieve unique code execution flows for each use case.
Adopting the automation testing practices and software development principles will help build a robust set of reusable code blocks.
CONS:
- More abstractions in the code
- Longer onboarding time
- Higher upfront development cost
More granular decomposition leads to a more significant number of the small building blocks, leading to increased demand for various abstractions. And in general, it increases the time required for the development. But it is only during the adoption of the approach. Once you get used to it, you will have a defined set of abstractions, and you will spend on that decomposition the same amount of time as you would do writing the code in a fashion that you did before. To ease the reuse of those units, you can collect them into the modules based on their responsibilities (use GRASP as a reference). That will help mitigate the cons of that approach as well.
Another critical point is the purity of the code units. To achieve the easiest reusability experience, keep the code units pure. Pure means without side effects. Side effects are any I/O operations or operations that change the state that is outside of the function. Pure units of the code produce the same output for the same input, no matter when and how many times in the raw it was executed. (To get a better understanding of purity, you can read more about pure functions and immutability)
Divide and
conquercompose
One of the cons of the approach I highlighted is increased upfront development time, which seems to contradict our goal of decreasing it. It is partially valid only for the upfront cost. To gain interest, you need to invest at the beginning. The approach's benefits will be much more visible along with the projected growth. The high upfront costs could be unaffordable luxe at the beginning of the project, for the startup, for the prof of concepts and many other cases. As we stated at the beginning of the article, "the software is useless until it is delivered to its user and serves user needs". Here is the place for the last piece of the puzzle, which will reveal the whole picture.
In the beginning, we discussed the evolutionary nature of the requirements for the software. We should avoid resisting that nature trying to prevent changes. Instead, we should embrace them. Literally speaking - aligning the natures of the requirements and the application. Because requirements and the application are just different representations of the same thing - requirements are the mental model, and the application is a physical representation. So we must follow the evolutionary approach while building the applications.
The evolutionary approach is about expecting changes and leaving room for them. I want to emphasise we are not trying to predict the changes and bring some redundant flexibility (which you won't probably need). No, it is about leaving the space for new functionality and the ability to adapt to the new requirements.
How does it work? As I said, our application is a set of small reusable units composed differently. So what do we do if we receive the new event utterly different set of requirements, we will recompose our robust, reusable units in a new way. Like LEGO blocks, we could build anything from them with sufficient quantity.
It sounds excellent but is too generic, and it is unclear how it applies to particular cases. So let's imagine we just started working on the project; we don't need to reuse any code so far. It is great. You develop the features, and when you get the same functionality that needs to be implemented again, you start the code extraction. You don't need to extract it beforehand or create some overhead yet. Don't try to solve the issues that are not existing and probably will never exist*.
* At least in my experience, the probability of hitting the desired extensibility point was pretty low, and usually, that redundant flexibility created so dramatic maintenance overhead, which slowed down the evolution of our code) Literally speaking, it is like a premature** optimisation process, you sacrifice code's readability and maintainability optimising most likely code that is outside of the hot path or the bottleneck.
** By premature, I mean the optimisation that is not backed by the date. Ideally it should be production data from the user-facing system; otherwise, it is an optimisation of the assumptions that will increase the time to market for your product.
With the current approach, we are still not embracing the changes. We are just solving today's tasks. So how can we embrace them?
We need two key things for that:
- We need to have low-cost extensibility points in our code.
- We need to be confident that new changes didn't affect existing functionality.
Let's start with confidence. To be sure that nothing is broken in our solution, we need to have our code covered with automated tests. Tests from all walks of life - unit tests, integration, e2e, etc.
It is clear with confidence, so what with extensibility points for our code? Here we get to the previously stated idea of dividing and composing things. So the idea is to split your code into small units from the beginning. The smaller pieces are - the more potential extensibility points you get. By that, I mean you will have more places where your application could adjust its behaviour to the new requirements.
As a bonus, we get the improved testability of the overall solution - smaller building blocks are much easier to cover with tests. Another plus is that we can minimise code branching. We can compose a new flow by reusing old building blocks instead of trying to squeeze a new functionality within the existing one. The fewer branches in the code are, the more likely the code will have a single responsibility, and as a result, it will be much easier to reason about it. The neatest thing here is that you will get used to such granular development quickly, and it won't bring much overhead. Even if it will, you will save a lot of time on writing tests, reading and reasoning about the code, and debugging it.
One more thing, making code reusable and available for other parts of the application or other applications will be as easy as copying&pasting the code units from one file to another. That is what I mean by evolutionary development - you are writing the code you need, which is beneficial here and now. And you are approaching it in a way that is embracing the future changes. That is like if you put 100$ in your bank account and get 90$ or even 101$ as a cashback immediately.
Let's sum it up and highlight the main points:
- Code duplication is ok if you can describe its purpose. E.g., We are duplicating the composition of our code units with slight adjustments. Why? To have two flows that have single responsibility each and avoid branching. Why is it beneficial for us? That will improve code readability, testability, and maintainability and ease its future refactoring.
- Code reuse can speed up and slow down the development process depending on the chosen approach.
- Divide the code into smaller pieces, automate their testing and compose them in various manners to achieve different behaviours.
- Follow the evolutionary approach. You don't need to predict the future, but you need to prepare for it.
Top comments (0)