This post is the first in a series about Carbon Health’s transition to Yarn Modern (2+) Workspaces. In this post, we* will cover the following topics:
In the remaining posts, we will cover the rest of the transition to Yarn Modern (2+) Workspaces:
If you don’t know what a monorepo is, check out this page for more details.
We don’t claim to be experts in Yarn Modern Workspaces, rather this is an account of our experience that may help others with growing codebases and similar challenges. In fact, there were several points along this process where we were not exactly sure what was going on, but figured out a workaround. If you notice anything that is incorrect or could be improved, please comment and we will address it.
*The royal “we” in this article is the Product Architecture team over at Carbon Health - compromised of Nick DeRobertis, Hanah Yendler, Miguel Bermudez, and James Baxley.
Carbon Health was founded in 2015 and for many years, we had only a single React Native code base. It was a monolithic TypeScript frontend that built two different applications: patient and provider. As our company services rapidly expanded in 2020, we added two more applications: enterprise and billing.
As both product teams and applications continued to grow in 2021, we quickly realized that we were repeating work between the code bases, and needed a way to share common components and utility logic such as API authentication. So the frontend architecture team decided to introduce a common package, which we named @ch/ui (aka Carbon Health User Interface, affectionately pronounced “Chewy”). But because our applications were in disparate parts of the codebase, we needed to ideate a few different ways to introduce this common package. To start, we outlined some goals.
We had a few goals for the developer experience with @ch/ui. To increase its adoption:
There were several options for integrating @ch/ui into the frontend applications:
We decided that packaging the files, either releasing on NPM or as a local tarball, would be counter to our goals because it would make hot reloading and keeping applications in sync with packages difficult (goal 1 🔥 + goal 3 ♻️) since the end application would be decoupled from the package sources.
There are ways to meet the above goals with a packaging solution, but they require extra steps: We could set the version of @ch/ui as "*" in each applications' package.json so that it would always use the newest version (goal 3 ♻️).
For goal 1 🔥 , npm link allows engineers to point to the local package files rather than the released package, which would allow changes to be reflected immediately. Unfortunately, the additional steps required would go against goal 4 🏃🏾 (no new workflow). We could have written some scripts to automate npm link and still achieve goal 4, but ultimately, we chose to move forward with the file linking approach, as we thought it would achieve all the goals without workarounds.
This was the initial solution we landed on, as it fulfilled all of our goals and seemed like the the easiest path forward.
In the package.json for each of our frontend applications, we had "@ch/ui": "file:../../applications/chui" so that we could consume the current library in all the applications. We used this approach for a few months with a couple of variations, and it worked to some extent.
With transpiling in the application, @ch/ui's source files are treated as application source files and are transpiled together. We first tried to treat the code as part of the application project by referencing the files in the application TypeScript, Metro, and Webpack configurations. While this worked, it was not ideal to have to add a lot of specific configuration to every application. If in the future we had a lot of packages, this would create a lot of work.
The major issues with the file linking approach were:
We discovered that the file link does not automatically update in all situations — though to this day we are still uncertain specifically when or why. In our initial testing, we were able to get apps hot reloading with changes to @ch/ui. Once we merged the file linking changes, we started having engineers' environments break due to not being able to find files from @ch/ui. The solution was to run yarn add file:../../applications/chui in the application so that yarn would pick up the current files. Some engineers had to run this every time for any change to a @ch/ui> file!
So the file linking introduced a major change to workflow, and it didn't always properly allow the application to reload from changes to @ch/ui. This broke two out of our four goals and prevented product engineering teams from investing in @ch/ui because it was hard to work with. It also prevented our team from refactoring our remaining applications to use @ch/ui, as we were still scrambling to fix the developer experience issues.
At this point, we needed to find a new solution.
This experience shifted our assessment of work required for the different possible solutions. So, we put restructuring the applications as JS monorepo (solution 1 above) back on the table.
The whole point of the JS monorepo structure is to be able to easily share code between related packages and applications. It seemed well-suited for our use case: it should hit the four goals above we are trying to achieve. Further, we know that the structure has worked well for many others (e.g. Babel and Jest), so we had some confidence it could work for us. Therefore, dun dun dun 🎉, we decided to implement a JS monorepo!
In the next blog post in the series, we will discuss how we selected Yarn Modern Workspaces out of the JS monorepo options and the process of transitioning to it.