Code re-usability levels

By Guy Y.

July 9, 2020

In my last post, I wrote about types of WET/DRY code and how they affect the project’s 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.

In this post, I’ll dive a bit deeper into DRY code design. I’ll use NPM and TypeScript examples, even though the concept can be applied to other cases as well.

What does “re-usability” mean?

When we say a piece of code is DRY we mean it is “re-usable.” However, the scope of re-usability can vary. A piece of code can be re-usable in some cases, but useless in others.

I guess there may be more formal terms and names for the patterns I suggest below, but I like to think of code as having a level of re-usability. The higher level, the broader is the re-usability scope. Low-level code is re-usable only within the context of a single class or file. High-level code can be reused in multiple projects.

The trade-off we pay for “re-usable” 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 as 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.

// cat.component.ts
class CatComponent {
    cats: Record<string, string>[] = [
        { name: 'Mistzi', age: '3' },
        { name: 'Fluffy',  age: '2'},
        { name: 'Casper', age: '5'},
    ];

    findByName(name: string) { 
        return this.cats.find(c => c.name = name);
    }

    findByAge(age: string) { 
        return this.cats.find(c => c.age = age);
    }
    
    render() {
        ...
    }
}

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.

// cat.component.ts
class Cat {
    cats: Record<string, string>[] = [
        { name: 'Mistzi', age: '3' },
        { name: 'Fluffy',  age: '2'},
        { name: 'Casper', age: '5'},
    ];

    private findBy(key: string, value: string) { 
        return this.cats.find(c => c[key] = value);
    }
    
    findByName(name: string) { 
        return this.findBy('name', name);
    }

    findByAge(age: string) { 
        return this.findBy('age', age);
    }
    
    render() {
      ...
    }
}

DRY level 2: Cross class/module

Our Cat component now is nice and DRY, but this findBy is 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 a code sharing mechanism.

// components.ts
class BaseComponent { 
    data: Record<string,string>[] = [];

    findBy(key: string, value: string) { 
        return this.data.find(c => c[key] = value);
    }
}

class CatComponent extends BaseComponent {
    data: Record<string, string>[] = [
        { name: 'Mistzi', age: '3' },
        { name: 'Fluffy',  age: '2'},
        { name: 'Casper', age: '5'},
    ];
    
    findByName(name: string) {
        return this.findBy('name', name);
    }
    
    render() {
      ...
    }
}

class CarComponent extends BaseComponent {
    data: Record<string, string>[] = [
        { model: 'Honda'},
        { model: 'Skoda'},
        { model: 'Kia'},
    ];
    
    findByName(model: string) {
         return this.findBy('model', model);
    }
    
    render() {
      ...
    }
}

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. In 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 in other files.

// collection.service.ts
export class CollectionService() {
  static findBy(
    data: Record, key: string, value: string){
    return data.find(c => [key] = value);
  }
}

// cat.compoent.ts
import {CollectionService} form 'collection.service';

class CatCompoent {
    data: Record<string, string>[] = [
        { name: 'Mistzi', age: '3' },
        { name: 'Fluffy',  age: '2'},
        { name: 'Casper', age: '5'},
    ];
    
    findByName(name: string) {
        return this.collectionService
              .findBy(this.data, 'name', name);
    }
}

// car.controller.ts
import {CollectionService} form 'collection.service';

class CarController {
    data: Record<string, string>[] = [
        { model: 'Honda'},
        { model: 'Skoda'},
        { model: 'Kia'},
    ];
    
    findByName(model: string) {
        return this.collectionService
               .findBy(this.data, 'model', model);
    }
}

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 or medium project, Level 3 code is fine. It provides 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 in the examples above), 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 re-usable in any framework.

We’ll move the findBy into a pure TS method, with no dependencies whatsoever.

// collection-utils.ts
export function findBy(
data: Record, key: string, value: string) {
  return data.find(c => c[key] = value);
}

// cat.compoent.ts
import {findBy} form 'collection-utils';

class CatCompoent {
    data: Record<string, string>[] = [
        { name: 'Mistzi', age: '3' },
        { name: 'Fluffy',  age: '2'},
        { name: 'Casper', age: '5'},
    ];
    
    findByName(name: string) {
        return findBy(this.data, 'name', name);
    }
}

// car.controller.ts
import {findBy} form 'collection-utils';

class CarController {
    data: Record[] = [
        { model: 'Honda'},
        { model: 'Skoda'},
        { model: 'Kia'},
    ];
    
    findByName(model: string) {
        return findBy(this.data, 'model', model);
    }
}

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 re-usable in multiple projects, but it is not enough by itself.

To increase the re-usability 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 of a 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’s important to note that once we start working with packages, we add workflow 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.

Polylith guidelines

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’s 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 behave in a weird manner. 

  • Libraries are standalone, meaning 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 two packages, one for our main app, and one for the collections-util library.

- packages
  - main-app
    - models
    - controllers
    - package.json
  - collection-util-lib 
    - package.json

Structure level 2: Cross application

Some of our sub-packages are awesome, we decide they would be useful in 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, but 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 understandable without digging into the code.

Summary

Code reuse has many levels. It all depends on your project and 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 in the long run for any team.