Skip to content

Monorepos and workspace tables

Markdown for agents

This guide checks the architecture of a monorepo with one config at its root. A table says which apps and packages may use which. A second rule makes each app list the packages that it imports.

  • Read Getting started.
  • Your monorepo uses workspaces of npm, pnpm or Yarn, so that each workspace package is linked in node_modules.

The example has two apps and two packages:

  • archangel.config.ts
  • package.json
  • Directoryapps/
    • Directoryapi/
      • package.json
      • Directorysrc/
        • index.ts
    • Directoryweb/
      • package.json
      • Directorysrc/
        • index.ts
  • Directorypackages/
    • Directorycontract/
      • package.json
      • Directorysrc/
        • index.ts
    • Directoryui/
      • package.json
      • Directorysrc/
        • index.ts

An app imports a package of the workspace by its name, for example @acme/ui. The package manager links node_modules/@acme/ui to packages/ui. Archangel follows the link, as Node and TypeScript do. So the import goes to a file in packages/ui, and a rule about parts judges it as an import of that file.

  1. Write one config at the root of the monorepo, with one part for each app and each package:

    archangel.config.ts
    import { defineConfig } from '@enhance-eng/archangel';
    export default defineConfig({
    parts: {
    web: 'apps/web/**',
    api: 'apps/api/**',
    ui: 'packages/ui/**',
    contract: 'packages/contract/**',
    },
    rules: ({ mayImport, rule }) => [
    mayImport('workspace', {
    web: ['ui', 'contract'],
    api: ['contract'],
    ui: [],
    contract: [],
    }, {
    because: 'An app uses packages. A package never uses an app, and the contract depends on nothing.',
    }),
    rule('declared-packages', {
    files: 'apps/*/src/**',
    mayNotImport: { packages: ['*'] },
    dependency: 'undeclared',
    because: 'Each app lists what it uses in its own package.json, so it builds alone.',
    examples: {
    rejected: [{
    'apps/web/package.json': '{ "name": "@acme/web" }',
    'apps/web/src/a.ts': "import 'react';",
    }],
    },
    }),
    ],
    });

    The table workspace says:

    • The web app may use the UI kit and the contract.
    • The API may use only the contract.
    • The UI kit and the contract may use no other workspace.
  2. Check the monorepo:

    Terminal
    $ npx archangel check
    error[may-import]: api may not import ui, through "@acme/ui": api may import only contract, and itself
    --> apps/api/src/index.ts:2:23
    |
    2 | import { Price } from '@acme/ui';
    | ^^^^^^^^^^ api may import only contract, and itself
    |
    = help: remove the import, or move the code to a file whose row permits the import
    = note: rule: workspace.api
    = note: because: An app uses packages. A package never uses an app, and the contract depends on nothing.
    error[may-not-import]: apps/api/src/index.ts may not import "zod", because files in "apps/*/src/**" may not import any package that apps/api/package.json does not list
    --> apps/api/src/index.ts:3:19
    |
    3 | import { z } from 'zod';
    | ^^^^^ remove the import, or move the code to files that may import it
    |
    = note: rule: declared-packages
    = note: because: Each app lists what it uses in its own package.json, so it builds alone.
    checked 5 files and 7 imports: 2 problems
    For what a code means, run `archangel docs may-import` or `archangel docs may-not-import`.

    The check finds two problems. The first is from the table: the API imports the UI kit. The second is from the rule declared-packages, in the next section.

Here is the API file:

apps/api/src/index.ts
import type { Order } from '@acme/contract';
import { Price } from '@acme/ui';
import { z } from 'zod';
const order = z.object({ id: z.string(), total: z.number() });
export function parseOrder(body: unknown): Order {
Price({ cents: 0 });
return order.parse(body);
}

In a monorepo, a package that one workspace installs is often visible to all of them. An app can then import a package that its own package.json does not list. It works on your laptop and fails when the app builds alone.

The rule declared-packages refuses this. It uses two options:

  • mayNotImport: { packages: ['*'] } names each outside package.
  • dependency: 'undeclared' limits the rule to packages that the nearest package.json of the importing file does not list.

The API imports zod. The root package.json installs it, but apps/api/package.json does not list it:

apps/api/package.json
{
"name": "@acme/api",
"private": true,
"type": "module",
"main": "src/index.ts",
"dependencies": {"@acme/contract": "*", "fastify": "5.6.0"}
}

The API also imports @acme/ui, which its package.json does not list either. The rule does not refuse it, because the import goes to a file of the repository, not to an outside package. The table judges it.

To find packages that are only in devDependencies, use dependency: 'dev'.

Terminal
$ npx archangel test
pass declared-packages rejected #1 apps/web/package.json, apps/web/src/a.ts
1 examples, none failed; 0 rules without a rejected example
4 table rows need no written example; `archangel rules` shows what each covers

The example of declared-packages writes its own package.json. A rule with dependency needs files that you write, because the text of the manifest decides the result. One-line examples do not work here.

Terminal
$ npx archangel rules
workspace 4 rows archangel.config.ts:11
because: An app uses packages. A package never uses an app, and the contract depends on nothing.
workspace.web: web may import only ui, contract, and itself
1 file · 2 imports judged · 0 problems · 0 exceptions
workspace.api: api may import only contract, and itself
1 file · 2 imports judged · 1 problem · 0 exceptions
workspace.ui: ui may import only itself
1 file · 0 imports judged · 0 problems · 0 exceptions
workspace.contract: contract may import only itself
1 file · 0 imports judged · 0 problems · 0 exceptions
workspace.every-file-has-a-kind: a file in no row is not checked here; add `files` to check it
4 files · 0 problems
declared-packages archangel.config.ts:20
because: Each app lists what it uses in its own package.json, so it builds alone.
files in apps/*/src/** may not import * that the nearest package.json does not list
2 files · 1 import judged · 1 problem · 0 exceptions
Not judged by any rule: 0 files, 0 imports between repository files.
Outside packages that no rule names: none.

Archangel uses the nearest config at or above the folder where it starts. One config at the root can check all workspaces in one run, and a table can say how they depend on each other. A config in a workspace sees only that workspace. Use one root config for the rules between workspaces.

  • Import options lists dependency, specifier, instance and within.
  • Next.js shows how to use a preset for one app, with its root.