How We Transitioned to a JS Monorepo, Part 1

Product Architecture Team
September 20, 2022

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:

  • Challenges of our existing frontend architecture and tooling, including reusing logic and components across multiple applications
  • Our specific goals for the ideal state of the frontend
  • Exploration and evaluation of different potential solutions
  • Our first, ultimately unsuccessful attempt to solve the problems: file linking

In the remaining posts, we will cover the rest of the transition to Yarn Modern (2+) Workspaces:

  • The process of switching multiple applications and a package to Yarn Modern Workspaces
  • Implementing the restructure while dozens of engineers were making changes daily
  • Real-world benefits, and future directions
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.  

Challenges of Our Original FE Codebase

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.

Goals for common package development

We had a few goals for the developer experience with @ch/ui. To increase its adoption:

  1. 1. We should be able to modify @ch/ui and have the end application hot reload the change in development 🔥
  2. 2. We should be able to modify @ch/ui and application code in the same pull request 🤝
  3. 3. Applications should always be in sync with the newest @ch/ui ♻️
  4. 4. There should not be a major change to workflow: still just yarn install and run the app 🏃🏾

Solutions Exploration

There were several options for integrating @ch/ui into the frontend applications:

  1. 1. Restructure the applications and package as a JavaScript monorepo
  2. 2. Release it as a private NPM package
  3. 3. Build a package tarball locally
  4. 4. Use file: links in package.json to reference the files directly 

Solution 1: Restructure to a JS Monorepo

We first eliminated restructuring the applications as a JavaScript monorepo because it would be time-consuming and take a lot of work. Plus, previous experiences with tools like Lerna led us to believe that this solution might make for a worse developer experience.

Solution 2 & 3: Packaging

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.

Solution 4: File Linking

This was the initial solution we landed on, as it fulfilled all of our goals and seemed like the the easiest path forward.

File Linking Approach

Initial Setup

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.

Considering that we have a TypeScript code base and clients run JavaScript, the sources need to be transpiled. With a standalone application, this is done all at once with the application source files. Once we added @ch/ui as an external TypeScript package, we needed to solve the issue of where to transpile in the application or transpile in the package.

Transpile TypeScript in the Application

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.

Transpile TypeScript in the Package

With transpiling in the package, sources are first converted to JavaScript and shipped to applications already transpiled. We added a postinstall step to @ch/ui to have it transpile TypeScript and produce a dist folder with JavaScript. This allowed us to remove all the project-specific configuration to work with the package. The main disadvantage is when we would “Go to definition” in our IDEs, it would point at generated type definition (.d.ts) files rather than the original source files. This made it difficult to find and understand the implementation behind the @ch/ui utilities.

Issues with the file linking approach

The major issues with the file linking approach were:

  • Hot reloading didn’t always work 😥
  • It required a manual file linking step for each developer 🔗
  • It either required a lot of project-specific config (TS in App) ✍🏾 or made it difficult to view package sources (TS in package) 👁

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.

Reconsidering JS Monorepos - Enter Yarn Modern Workspaces

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.

Product Architecture Team

Product Architecture Team in Carbon Health - comprising Nick DeRobertis, Hanah Yendler, Miguel Bermudez, and James Baxley.  As a team, we really enjoy good documentation, going on many tangents, and cute fluffy animals.