Monitor Exports from Packages in Monorepos

When you maintain an npm package you are maintaining a public API surface that will change over time.

This is a chore but if you're working on a large project with many teams of developers, who all own different packages, and you have to publish all those packages regularly, well... then it becomes a problem. Downstream consumers will raise their Slack pitchforks if you're not careful!

How do you track and monitor the changes to this public-facing API in a large project like a monorepo?

We use a basic approach is a combination of codeownership and convention-based module tests using Jest inline snapshots which I explain below.

One of my colleagues came up with this idea, thanks Anders! 🙏

What's exported from a package?

In JavaScript (or TypeScript) projects, we use the export keyword to expose an API in the module index, you've likely seen this:

export * from './wood-cutting';
export * from './camping';
export { useFishingContext, useFishingHook } from './fishing';

There are 2 things to note here:

  1. We have no idea what is being exposed from wood-cutting and camping modules. We have to dive into and follow the export chain to figure that out.
  2. We have a good idea of what is being exposed from fishing

One approach when building a consumer-facing package like this is to use explicit named exports in the index module (we will set aside the fact you can deep import, something which we've disabled through eslint in our projects).

Export star is a double-edged sword

It's very common for the internals of a package to use export * for exposing folder-based modules and for namespacing. This is nice because it makes it much easier to maintain inside the package.

But when it's used to expose things at the top level it introduces maintenance complexity – because we don't know what's exposed to the public.

You could enforce always being explicit in the root module and that can work. However, it could also be burdensome when a module has 100s of exports (should it be split? That's a different discussion).

Even so, we still have a problem on Big Teams.

Mo' monorepo, mo' problems

We have 80+ packages in our monorepo. All packages get released at once weekly. Packages are owned by multiple teams and we have 80+ engineers working in this repository.

What we've seen is the following problems can happen over time:

  1. A new API is added but someone forgot to export it
  2. A new API is added and it automatically gets made into a public API even though it was supposed to be internal
  3. An API was removed and is no longer exported
  4. An API was changed and its behavior changed

The fourth issue should be handled through tests and socializing the fact that downstream consumers will be affected. We use deprecation notices and migrations so folks make that process easier which updates the CHANGELOG and can run/record commands with versioning.

So let's focus on the first three and a quick process we implemented to help track changes to public module exports.

The module exports test

You will be disappointed by how simple this ends up being 😅

We use GitHub CODEOWNERS to tag a special "custodial" group when certain things change in the monorepo: think package.json dependencies, yarn.lock files, and now, module export tests.

A module export test is a regular test file that imports the surface area of a module and tracks a snapshot of the public-facing interface over time. Combined with a CODEOWNERS file, this means teams will be notified if a contributor ends up changing the public API surface by adding an API, removing an API, or renaming an API so it can be reviewed for whether it requires a migration or deprecation notice.

Here's an example that should illustrate the idea:

/* packages/survival/__tests__/module.test.mjs */

import * as moduleExports from '..'

describe("module exports", () => {
    
  it("should not change unless reviewed by owners and custodians", () => {
      
    expect(moduleExports).toMatchInlineSnapshot(`
Object {
  "collectFirewood": [Function],
  "cutWood": [Function],
  "getCampingSite": [Function],
  "useFishingContext": [Function],
  "useFishingHook": [Function],
}
`);
  })
})

And the corresponding CODEOWNERS entry for some of our conventions:

# Custodial Ownership
module.test.mjs       @custodians
yarn.lock             @custodians
package.json          @custodians

# Team Ownership
/packages/survival/   @survivors

You can check out a tiny sample repo to play with it.

This was a quick and easy way for us to help track package changes and notify the owners if someone outside their team managed to change the public API.

This won't solve everything

It's important to mention that this doesn't help with signature changes.

We use TypeScript so we could take the same approach against the root index.d.ts that gets generated for dists but that would likely have to be done separate from Jest. We've talked about centralizing the module tests in one place which consumed the dist packages instead of being within the package. This would allow us to do more kinds of tests/checks.

This is just one small example of interesting scaling issues monorepos present which is why investment in tooling is critical.

What pains have you run into using monorepos or on large repositories? What have you done to alleviate that pain?