Modularizing Applications with Ease
Splitting web applications into multiple packages
In this article, I want to explain how beneficial it can be to split your application into multiple packages, especially when you’re working with several teams or on a bigger web application.
Why with ease?
Because it’s easy — you can just take known best practices like extracting code into packages and combine this approach with the workspaces feature available in package managers like yarn, pnpm or in npm v7.
Let’s have a short look at the nature of packages.
Packages are isolated by nature. They can be reused from other packages and expose functionality to the consuming packages. The exposing part can be called the interface of a package.
You can shape this interface using the main, module or files keys in the package.json
file, which can point to an index.js
file that exposes additional values, functions or more complex types. But you can also find other important information in the package.json
file, like dependencies, name, version, contributors and more (see the npm documentation)
With packages, you can now use the open-closed principle on a higher level as with normal JavaScript files.
Where can packages help you?
In general, using packages can help you to ensure a better overall architecture of your application. The aforementioned benefits isolation and open-closed principle provide an often-underestimated value. These principles help developers to think about what they want to give to the outside world, and what they really need to fulfill the task when they are building new features.
It is easier to track the dependencies the package relies on than to look inside every file and analyze the import statements. If you ever had a root package.json
with more than 200 dependencies, maybe you know how hard it is.
If you look at it from the package owner perspective inside your application or on a company level, it’s easier to find out where the package is consumed and how you share packages across applications.
With this list in hand, you can approach your colleagues and plan different migration scenarios with them (small tip: keep the contributors list up-to-date).
For example, you can plan a step-by-step migration to a newer version while others are still using the previous version — instead of doing it in one big step — which might block more than one team for some time.
Extracting and reusing code is much easier when it is properly isolated from the beginning. Often you implement functionalities or components that will be needed in other applications. If that happens, the next step is quite easy — just publish the package.
In general, the touchpoints between parallel working teams are much smaller. Everyone is mostly working on their own packages. So they don’t interfere with each other, which reduces merge conflicts.
Using standard fields like contributors or author in the package.json
file makes it more transparent which team owns that feature or package.
It also reduces the risk of introducing circular references. Of course, this can still happen, but it’s much more transparent when you check the dependencies.
A nice side-effect is that you don’t need to worry about long relative paths in your import statements. Just use import { awesomeFunction } from '@my-app-scope/awesome-utils'
.
When should I use a package inside my application?
Here’s a checklist of questions that can help you decide when to add a new package to the application:
- Is it complex and will it be used in other places in the application?
- Is that functionality owned by a specific team?
- Should the code be easily exchangeable at build or runtime?
- Should it be easy to remove it from the code?
- Do I want to make it available outside of the repository?
- Is it being used in two other packages?
- Does the new package add value to the architecture of the application? (Spoiler alert for my next articles: Shaping an Application with Packages and Optimizing multi-package apps with TypeScript Project References.)