Folder structure for organizing an Nx/Angular monorepo

Setup

At my company we have a sizable Angular codebase split across two repos. This obviously isn't ideal, so last Fall we started putting plans together to consolidate the two into a single monorepo.

Thankfully one of the two repos was already set up with Nrwl's Nx tooling (we'll call this repo Nx), which we already had 3 apps in. And we had started down the road of moving common code over to Nx so the apps being built there would have access to the common services/interfaces/etc. as the main app in the other repo (we'll call this one Rails). We accomplished this by porting some common code from Rails to Nx then packaging that code up into a library which Rails then imported. But now we wanted to move the remaining, non-shared code over to Nx as well. We call this The Great Migration.

Problem Space

Altogether the remaining Angular code in Rails added up to about 700 components and over 100 services. How could we migrate all this code in a sane way in a reasonable amount of time while not interfering with feature work and bug fixes? The ideas started brewing and I started researching.

Thankfully about this time one of my colleagues tipped me off to a couple of resources. One was this blog post on opinionated guidelines for large Nx/Angular projects (most of this approach was taken from there). The other was an ebook by Nrwl, the company behind the Nx toolset we're using. Both suggested a folder hierarchy in which you create libraries that are then imported into an app. The folder hierarchy was important since linting would be based on this, and it would be how we control dependencies between libs. Each resource suggested slightly different folder hierarchies, both of which made sense but they didn't feel like they fit our domain quite like I wanted them to. So I got to work figuring out how to mold these ideas into something that worked for us.

The Solution

After a few iterations I hit upon a combination that felt like a fit.

┌─► app1
│ │
│ ├─► domain1
│ │ │
│ │ ├─► api
│ │ │
│ │ ├─► lazy
│ │ │
│ │ └─► shared
│ │
│ └─► domain2
│   │
│   ├─► api
│   │
│   ├─► lazy
│   │
│   └─► shared
│
└─► app2
  │
  └─► domain1
    │
    ├─► api
    │
    ├─► lazy
    │
    └─► shared

These are all libraries, so are all under the libs folder in the Nx repo. The first level folder is the app name. Only code specific to this app will go in here. The second level folder is the name of the domain within the app. And finally we have the api/lazy/shared folders, which is where the libraries actually are. So in this example we would have an app1-domain1-api lib and an app1-domain1-lazy lib.

We put some rules around what can go in each type of lib. First, api libs are only allowed to export services that talk to external sources. Usually these services will inject the HttpClient, though some may be services that talk to other third-party APIs. Next the lazy lib can only export lazy-loaded modules ready to be wired up to a router. And finally, shared is for code that will be needed outside this grouping. For example, say a component in domain1 is also needed in domain2. It isn't a standalone lazy-loaded module so it would need to go in the app1/domain1/shared lib. For code shared between apps we created a top-level common folder which has features/domains under it with the same api/lazy/shared structure.

Thankfully most of our app was already setup as lazy-loaded modules, so now with this structure we can migrate entire sections of the app at once. Some have to be refactored a bit, especially if the module contains shared components. One bonus of this structure we found was the rules we had set in place around what could go into each type of lib kind of forced us into good decisions. For example, you move a module over to lazy but in doing so you find some parts of the app that need a component from there. So the rule requiring only lazy-loaded modules be exported from lazy means you need to move that component somewhere else. It doesn't belong in api, so that means it has to go to shared.

The Migration

Now that we had an idea of what the folder structure should look like, how do we actually move these modules from one repo to the other? Turns out this approach made the migration path pretty easy.

  1. Cut the folder containing the lazy module from the old repo
  2. Paste the folder in the appropriate app/domain/lazy lib
  3. Export the lazy module from the lib's index.ts
  4. Update the component selectors and any imports that need to be updated

While going through these steps, often we find imports that are referencing something that's still in the old repo. We have to move the dependencies in addition to the lazy module to the new repo. Generally these things will belong in the api or shared libs, depending on if they're a service or a component/pipe/etc. Effort should be taken to determine the best living place for these things. Are they related to this lib's feature/domain but just happen to be used outside of it? Or are they more central, where no particular feature/domain owns them? In the later case we should move them up a level to something like an app/common/shared lib.

Once we had this groundwork laid, it was time to divvy up the work between teams. Each team was assigned ~3 modules to migrate. As of this writing we're about 3/4 of the way done.

Tooling

None of this is possible without tooling. Nx gives a lot of tools out of the box, but you'll still need to invest time into developing tooling around Nx to make it work for your specific CI setup. For example, we knew we were going to need to generate a lot of libraries for this to work. While Nx has built-in generators, we needed to make small tweaks to the default generated lib. And when you're generating maybe 5 a week, those small tweaks add up and the opportunity to miss one of those tweaks increases. So we developed our own library generator, built on top of the one Nx provides, thereby automating all those small tweaks we needed. Now generating a new lib is as simple as ng g feature-lib app1/domain1/api and the correct tags (used for linting import rules) are applied and other small company-specific tweaks are automated away.

Another example is earlier in the development phase of the Nx repo we wanted to run each affected lib's spec run in a separate CI job, essentially parallelizing the spec run but with lots of cheap machines instead of multithreading on a single large machine. At the time this wasn't something that Nx provided out-of-the-box (though I believe Nx 12.3 is bringing something like this?) so we wrote a series of scripts that would take the output from the Nx affected command, store it in an env file, then use the results in each CI job to determine whether that lib's specs should be run or not.

The Finish Line

After all modules have been migrated into their respective libraries, all that will remain is to wire the routes up in the app. And some of that work can be done around the migration.

Dan Smith

Dan Smith

Cleveland, OH
Find me on Mastodon