In my last post, I wrote about types of WET/DRY code and how they affect the project health over time. I mentioned code design as one of the sources of WET code.
In short, DRY code is easier to maintain and reuse.
At this post, I’ll dive a bit deeper into DRY code design. I’ll use NPM and TypeScript examples, but the concept can be applied to other cases too.
What does “reusability” mean?
When we say a piece of code is DRY, we mean it is “reusable”. However, the reusability scope can vary. A piece of code can be reusable for some cases, but useless for others.
I guess there may be more formal terms and names for the patterns I suggest below, but I like to think code as having a level of reusability. The higher level, the broader is the reusability scope. Low-level code is reusable only within the context of a single class or file. High-level code can be reused at multiple projects.
The tradeoff we pay for “reusable” code comes in the form of dependency. The broader the scope, the more weight the dependency would have on the project. At some point, we may prefer WET code over the overhead of dependency management.
DRY level 0 - copy/paste
Code at this level is a WET as can be. The same piece of code is repeated multiple times at the same class/module.
For example, assume we have the following Angular’s component (I know it’s not a valid Angular code, I simplified it to get the gist). The findBy methods at the next snippet are very WET.
The only reason I can think of preferring such copy/paste code patterns is extreme time pressure.
DRY level 1: Cross method
The code at this level can be reused by methods at the same class/module.
All findByX methods logic are the same. We can level up this part of the code by introducing a single more abstract findBy method, that would be reused by all other findByX methods.
DRY level 2: Cross class/module
Our Cat component now is nice and DRY, but this findBy scoped to a single component.
Continuing the above example, we widen our scope to the full components layer of the application. Inheritance can allow such code sharing mechanism.
DRY level 3: Cross file
While the application is small, it can be convenient to keep multiple modules on the same file. However, it won’t scale as the app grows.
I also find inheritance to scale poorly. At the above example, inheritance feels forced, after all, Cars and Cats have very little in common. A more functional coding approach would allow us to avoid the pitfall of OOP scaling.
We’ll make the findBy method pure and introduce a helper module in the form of Angular service (again, not a valid angular code), that can be loaded at other files.
The more code we move into such pure helpers, the more flexible our codebase becomes. Plus we just gain a big bonus — small & pure code is easy to unit test!
Notice we do start to pay a price in the form of dependency, our Cat component depends on CollectionsService. Now imagine the CollectionsService also depends on some other services… The dependency tree starts to grow.
For small/medium project, Level 3 code is fine. It provides a great value for effort ratio. It really can go a long way.
DRY level 4: Cross framework.
While some of our code must contain framework dependencies (Angular at the above examples), it doesn’t mean we have to carry this liability everywhere.
The CollectionsService is dependent on an external source — the Angular framework itself. By removing such external dependencies we can make this code reusable at any framework.
We’ll move the findBy into a pure TS method, with no dependencies whatsoever.
I try to write in such a framework-agnostic pattern whenever possible. Preferring pure independent modules provides great flexibility in terms of code reuse.
Levels 0–4 are focused on code structure & design within a single project. Level 4 code has the potential to be reusable at multiple projects, but it is not enough by itself.
To increase the reusability scope even further, we’ll need to start thinking about the project structure and architecture.
Structure level 0: Monolith
With a monolith, all the code is bundled into a single project. We have one code repository, one build system, and one single shippable package.
This is a fine setup for most projects. But
- It would not allow sharing code with other projects.
- It would not scale as the app & team grows.
Structure level 1: Polylith
These days even single projects can become huge. Large teams would face scaling issues sooner or later. This is when we start feeling the aches monolith project of extreme size, and hearing buzz words such as “Micro front end”.
It’s time to introduce the concept of sub-packages (a.k.a libraries). It doesn’t mean we must switch our codebase into a multi-repo structure. We can manage multiple packages within a mono-repo setup in a polylith structure.
It is important to note that once we start working with packages, we add workflows complexity. We switch from a single build process, into parallel builds.
Luckily, some tools may help the heavy lifting, so such build systems can be defined once, and rarely requires updates.
- Angular support multi-project workspace, which is a great stepping tool to manage npm packages in a mono-repo.
- Lerna is also a great tool to manage multiple npm packages.
- NPM itself allow local packages via npm install <folder> command.
Packages dependency management can be a challenge. Without defining some ground rules, it would be very hard for the different packages to fit nicely together.
One risk is a dependency cycle — libraries fail to build because they depend on one another. We can reduce the risk by keeping the dependency tree as simple as possible:
- An application is the consumer of libraries, it is the root of our dependency tree.
- Libraries are by default tree leaves. They should not depend on one another.
Another risk is dependency collision. For example, library A depends on jQuery-3.5, while library B depends on jQuery-3.2.
- All library dependencies must be defined as peer deps, meaning they rely on the hosting app to supply needed deps.
Additional risks can be found in global state management. For example, both libraries A and B modify a shared object on the window object. Each library by itself may behave OK, but both in parallel may give out weird behavior.
- Libraries are standalone, they do not depend on external state. The hold internal state, and expose it via messaging systems (i.e events, observables…)
So to continue the above example, we can structure our code with 2 packages one for our main app, and one for the collections-util library.
Structure level 2: Cross application
Some of our sub-packages are awesome, we decide they would be useful at other apps too.
The jump from level 1 to 2 is simple. Most of the hard work was done by following the polylith guidelines of level 1. We just need to
- Extract the package into its own repository.
- Publish it.
This introduces new challenges, I won’t elaborate on those here. From a bird’s eye view, we now need to deal with
- Version management — The package users should be able to upgrade/rollback versions easily, and following semantic versioning conventions.
- Maintenance — users will find bugs and request changes from the package. Who would address those?
- Documentation — The package should be easy to use and understand without digging into the code.
Code reuse has many levels, it all depends on your project & team requirements.
Pure & framework-agnostic (DRY level 4) codebase, in polylith structure (structure level 1) can be very beneficial in the long run. I believe the initial setup investment would pay off on the long run for any team.