How to think about packages in a monorepo
A good software is one which is easy to change.
Software is never written, it is always rewritten. It changes and evolves everyday. Life expectancy of any code you write is forever. It stays in your system till it is deleted.
This code goes through lot of changes during its lifetime and so it has to be maintained. Maintenance is Debt. Business requirements change everyday and unlike real world, in software world you pay debt everyday.
Easiest code to maintain is one that was never written in the first place. Similarly easiest change to do is one when you don’t have to change anything.
And that is why it is extremely important that code you write should be one which is easy to change. Thereby it will be easy to maintain and will not accrue much technical debt.
In this blog post we’ll discover how to do that using monorepo. Many front-end open source libraries like react, babel, material-ui use monorepo to not only maintain their complex behaviour but also actively make huge refactors.
Monorepo is especially well suited for JS applications because they are worst kind of applications to maintain. JS lacks types, has no compiler, has mutations and front-end is full of accidental complexity and side-effects. All your front-end and back-end issues combine and snowball into user facing issues on production.
To put simply Monorepo is a style of handling development across mutiple packages in a single repository.
Traditionally monorepo is thought of as dumping multiple repositories in a single repository. People who love or hate monorepo think in these terms. Modern monorepo is bit more nuanced than these definitions. It is a combination of three main concepts.
- Cross package orchestration
- Package level dependency abstraction
- Versioning and release control
This story is not about any of these concepts, but rather about first class members of monorepo, packages. And why I think it can make your front-end architecture very easy to maintain. 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
Code with single responsibility is less likely to need changes. Therefore easy to maintain.
In monorepo, prefer to build small robust packages with 100% spec coverage. Writing specs will keep cost of change in your packages low for a long time.
These packages have very well defined single responsibility. Often small and very specific. They solve one problem at a time. Responsibilities that are not in package’s well defined scope are other package’s problem.
When this is implemented right, your repository will have many small packages. All packages including main app package will have very low complexity and super easy to reason or change.
Packages are explicit about their dependencies
Dependency abstraction is very useful feature in monorepo. It helps keep your code loosely coupled.
For example if auth package which handles your authentication behaviour depends on your main app package to work.
This is a sign that auth package is not a loosely coupled package. It is not truly modular. app package which has lot of side effects and huge surface area can easily cause auth to fail. It will be hard to predict how auth will behave at runtime. This makes both app and auth packages harder to maintain or change.
auth package should not depend on a huge package like app full of side effects. It should work independent of it without any dependency. In this scenario, you should extract the code auth uses into its own package.
If auth is dependent on app because it is using api related logic from app. Create a new package api which is pure without side effects and reuse this in both app and auth package. Remove app dependency from auth package and depend on decoupled api package instead.
Your auth package is now safely isolated from all possible future side effects with changes in app. Very easy to maintain and change.
auth package will become pure and will have very predictable behaviour. You can expect it to behave like pure function and behave exactly the same as long as there are no changes in its inputs or dependencies.
This way dependency abstraction gives you a high level dependency abstraction between different parts of your repo. You can use this abstraction to understand control flow or to keep packages loosely coupled at all times or to write side effect free code.
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 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 same behaviour as long as their inputs or dependencies don’t change. Which when we have small packages with single defined responsibility 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, and always debug for problems in current package that you’re working with. Suddenly your app will be much more easier to reason.
Code without side effects is also very easy to write specs for. 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.
One very good use case of packages is to solve tight coupling. Tight coupling makes change harder to do, because of side effects.
Make hard change easy and then do the easy change
This is perfect time to create a new package. This new package will have no context about outside world and will 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 app. Like how glove fit perfectly your hand.
Single responsibility packages have good clarity of what job they are set out to do. Cohesion is much more easier with loosely coupled modules. And when they are composed together with high cohesion, they will 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 outside world uses your module. It is based on open-closed principle, which states your packages should be open to extend and closed to modifications. This is a extra layer of abstraction on your packages defining its real world use case.
If interface of an package has no side-effects after refactor, you can safely assume your refactor is full proof. You should very at least test specifications of a package’s interface if not anything.
Simplest example of an interface you might have already encountered in 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 cohesion of loosely coupled pure packages. Your repository will start ageing in reverse. It will get modular and much more predictable with time.