How to think about packages in a monorepo

5 Aug 2021
7 min read

A good software is one which is easy to change.

Software is never written, it is always rewritten. It changes and evolves every day. The life expectancy of any code you write is forever. It stays in your system till it is deleted.

This code goes through a lot of changes during its lifetime and so it has to be maintained. Maintainance is Debt. Business requirements change every day and unlike the real world, in the software world, you pay debt every day.

The easiest code to maintain is one that was never written in the first place. Similarly, the easiest change to do is one when you don’t have to change anything.

And that is why it is extremely important that the code you write should be one that is easy to change. Thereby it will be easy to maintain and will not accrue any technical debt.

In this blog post, we’ll discover how to do that using monorepos. Many frontend open source libraries like react, babel, material-UI already use monorepo to not only maintain their complex behaviour but also actively make huge refactors.

Monorepos are especially well suited for js applications because they are the worst kind of applications to maintain. Js lacks types, compiler, has mutations and every inch of a user interface is full of side effects. All your frontend and backend issues combine and snowball into user-facing issues on production. To tackle this to some extent, we recently migrated to a monorepo.

To put simply monorepo is a style of handling development across multiple packages in a single repository. Traditionally monorepo is thought of as dumping multiple repositories in a single repository. People who love or hate monorepos think in these terms. Modern Monorepos are a bit more nuanced than these repository opinion wars. It is a combination of three main concepts.

  • Cross package orchestration
  • Package level dependency abstraction 
  • Versioning and release flow

And there were many reasons why we migrated to monorepo, not just for those three concepts. You can read about some reasons here if you are curious.

This story is more about the core reason to move to monorepo, packages. And why I think it can make your frontend architecture very easy to maintain and change.  How you can write that one good software which is easy to change.

For this, you need to understand how to think about packages in monorepo.

Build small robust single responsibility packages

In monorepos we prefer to build small robust packages with 100% spec coverage. These packages have a very well defined single responsibility. Often small and very specific. They solve one problem at a time. Responsibilities that are not in the package’s well-defined scope are other package’s problems.

Code that has single responsibility will have less changes, therefore easy to maintain. 

When this is implemented right, the repo will have many small packages. All packages including the main app package will have very low complexity and are super easy to reason or change.

Zoom w2luXqK.png
w2luXqK.png 16.7 KB View full-size Download

Packages are explicit about their dependencies

Dependency abstraction is a very useful feature in monorepos. It helps keep monorepository loosely coupled.

For example, if the `saga` package which handles our behaviour layer depends on the `app` package. 

    dependencies: {
        "@shaaditech/app": "1.0.0"

This is a sign that the saga package is not a loosely coupled package. It is not truly modular. App package which has a lot of side effects and a huge surface area can easily cause saga to fail. It will be hard to predict how the saga will behave at runtime. This makes both app and saga harder to maintain or change. 

saga package should not depend on a huge package like an app full of side effects. It should work independently without any dependency. In this scenario, you should extract the code saga uses into its own package. 

If saga is dependent on the app because it is using API related logic from the app. Create a new package `api` which is pure without side effects and reuse this in both the app and saga package. Remove app dependency from the saga and depend on decoupled API package instead.

    dependencies: {
        "@shaaditech/api": "1.0.0"

Your saga package is now safely isolated from all possible future side effects with changes in-app. Very easy to maintain and change.

The saga package will become pure and has a very predictable behaviour. You can expect it to behave like a pure function and behave exactly the same as long as there are no changes in its inputs or dependencies.

Side-effect-less packages are easy to debug

A world without side effects is a simple place to live, grow old and write code peacefully

Packages are pure and don’t cause any side effects in other packages. Think of them as pure functions, but at the package level. They in turn depend on other smaller packages which again as you might have guessed are, Pure.

They behave just like pure functions in the sense that they will always exhibit the same behaviour as long as their inputs or dependencies don’t change. Which when we have small packages with well-defined responsibilities will happen very rarely.

These packages will be very easy to maintain for a very long time.

Since these packages are pure and without side effects when debugging a package. You can assume by default that your other packages live in a perfect world, with no side effects. Just like we assume the redux package will reduce all-out state operations without any side effects in the flow of your app. Always debug for problems in a current package that you’re changing. Suddenly, your package will be much easier to reason.

Code without side effects is also very easy to test. Your future test cases will thank you for it.

Low Coupling and High Cohesion

Loosely coupled packages have few other smaller pure packages as dependencies.

Make hard change easy and then do the easy change

One very good use case of packages is solving tight coupling. Tight coupling makes change harder to do, because of side effects.

This is the perfect time to create a new package. This new package will have no context about the outside world and have well-defined work to do. Hence it will have low coupling.

Cohesion simply means how well all your small building blocks like packages come together and serve the purpose of the app. Like how gloves fit perfectly on your hand.

Single responsibility packages have good clarity of what job they are set out to do. Cohesion is much easier with loosely coupled modules. And when they are composed together with high cohesion, they do their job much better than tightly coupled modules that are hard to maintain.

Packages have Interfaces

Interface in programming simply means that there is a defined way in which the outside world uses your module. It is based on the open-closed principle, which states your packages should be open to extending and closed to modifications. This is an extra layer of abstraction on your packages’ real-world use-case.

If the interface of a package has no side effects after refactor, you can safely assume your refactor is full proof. You should very at least test the specifications package’s interface if not anything.

The simplest example of an interface you might have already encountered in the js world is blank index files with imports and exports. Where you only export bits of code that you want people to use in your module and close off other private methods from usage.

And that’s all. If you can think of your app as the cohesion of loosely coupled pure packages. Your repository will start ageing in reverse. It will get modular and much more predictable with time.