Three layers of monorepo governance
Workspace governance means the rules that keep a monorepo coherent as the number of projects and contributors grows. These rules sit at three layers, and each layer can only see a certain amount of the workspace.
| Layer | What it inspects | Typical rules | When it runs |
|---|---|---|---|
| Per-file lint | Source files and their import statements | @nx/enforce-module-boundaries, no deep imports, banned packages |
Editor, pre-commit, lint target |
| Project graph | Projects, their configuration, and the edges between them | Every project has tags, required targets exist, no orphaned libraries | CI, against the whole workspace |
| Organizational policy | Metadata about people and process | Each project has an owner, a declared team scope, a lifecycle status | CI and code review |
ESLint works one file at a time. @nx/enforce-module-boundaries consults the project graph, but it only fires when a file contains an offending import. It cannot report what is absent. Examples include a new library created with no tags, which may never match a depConstraints entry and so is never checked. A project might also be missing a test target, or a package might have no owner listed. Lint also tends to run only on affected projects, so a project nobody touches is never re-examined.
The layers split along that line. Questions about code go in lint. Questions about the shape of the workspace go in project-graph checks. Questions about accountability go in policy. When a team has dozens of contributors and hundreds of projects, the second and third kinds of problem cause most of the drift. Per-file linting cannot see them.
What Nx conformance is and how it works
Lint rules see one file at a time, so they cannot answer questions like "does every project declare an owner?" or "does anything depend on a deprecated library?" Conformance is the layer built for those questions. It is part of Nx's enterprise offering rather than the open-source core, so check the current package names, commands, and licensing requirements against the Nx documentation for your version before planning around it.
A conformance rule receives the whole workspace: the project graph, each project's configuration, and its files. It returns violations attributed to specific projects. That attribution is what makes the system practical at scale. A failure in CI names the project and, through ownership metadata, the team responsible for it. Nobody has to dig through a single monolithic lint log to find out whose problem it is.
project graph + project configs + files
│
▼
┌────────────────────┐
│ conformance rules │ (built-in + custom)
└─────────┬──────────┘
▼
violations grouped by project
│ │
▼ ▼
CI check (fail or reports/dashboards
report-only) for platform team
Rules can usually be set to report without failing builds. You can use this to measure how much existing code violates a rule before you enforce it.
Who should adopt it
Conformance pays off for a platform team that governs a workspace shared by many product teams, where conventions now spread by tribal knowledge and code review. If one team owns the whole repo, tags plus ordinary lint rules are usually enough. Adopt conformance once enforcing policy by hand costs more reviewer time than the license and the rule-writing effort.
┌──────────────┐ ┌────────────────┐ ┌─────────────────┐
│ Project graph│ │ Project configs│ │ Project files │
│ (dep edges) │ │ (owners, tags) │ │ (source, config)│
└──────┬───────┘ └───────┬────────┘ └────────┬────────┘
└──────────────────┼────────────────────┘
▼
┌───────────────────────────┐
│ Conformance rules │
│ built-in + custom │
│ see whole workspace │
└─────────────┬─────────────┘
▼
┌───────────────────────────┐
│ Violations per project │
│ app-a: 2 lib-b: 0 ... │
│ project → owner → team │
└──────┬─────────────┬──────┘
│ │
▼ ▼
┌──────────────┐ ┌────────────────┐
│ CI check │ │ Reports for │
│ fail build │ │ platform team │
│ or │ │ baseline before│
│ report-only │ │ enforcing │
└──────────────┘ └────────────────┘
Conformance rules evaluate the project graph, project configurations and files together, then attribute each violation to a project and its owning team, which feeds either a failing or report-only CI check and platform-team reports.
The foundation: tags, depConstraints, and ownership
Before buying anything, get the free layer right. Give every project two tags in its project configuration: a scope:* tag for the business domain or owning area (scope:billing, scope:identity, scope:shared) and a type:* tag for its architectural role (type:app, type:feature, type:data-access, type:util). Two independent axes keep the rules small. You write "billing may depend on billing and shared" once, and "utils may depend only on utils" once. You do not enumerate every pair of projects.
Those rules live as depConstraints in the @nx/enforce-module-boundaries options of your ESLint config. Each constraint matches a sourceTag and lists the tags it may import through onlyDependOnLibsWithTags. The rule only has teeth if lint runs in CI as a required check, for example nx affected -t lint on every pull request. A boundary violation then blocks the merge before a reviewer has to spot it.
Tags describe what code is allowed to do. Ownership defines who answers for it. Map project directories to teams in CODEOWNERS, or record an owning team in project metadata, so every rule change and every lint suppression has a named approver. Unowned projects decay in a predictable way. Nobody has the authority to fix a violation properly, so someone adds an eslint-disable or widens a constraint to unblock a release. Their configuration also goes stale because no team feels responsible for cleaning it up.
Tag taxonomies drift too. New tags appear for one-off cases, and old ones stop meaning anything. Put the ESLint config and tag changes under the platform team's review.
// eslint.config.mjs (workspace root)
import nx from '@nx/eslint-plugin';
export default [
...nx.configs['flat/base'],
{
files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
// No blanket allow-list. Exceptions go through platform-team review instead.
allow: [],
// Every matching constraint applies, so a project tagged
// scope:billing + type:feature must satisfy both axes.
depConstraints: [
// Scope axis: a domain may use itself and shared code, nothing else.
{ sourceTag: 'scope:billing', onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },
{ sourceTag: 'scope:identity', onlyDependOnLibsWithTags: ['scope:identity', 'scope:shared'] },
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
// Type axis: layers may only depend on the same layer or lower ones.
{ sourceTag: 'type:app', onlyDependOnLibsWithTags: ['type:feature', 'type:data-access', 'type:util'] },
{ sourceTag: 'type:feature', onlyDependOnLibsWithTags: ['type:feature', 'type:data-access', 'type:util'] },
{ sourceTag: 'type:data-access', onlyDependOnLibsWithTags: ['type:data-access', 'type:util'] },
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
],
},
],
},
},
];
An ESLint flat config that encodes the scope: and type:* rules as two independent sets of depConstraints for @nx/enforce-module-boundaries, so each policy is written once instead of once per pair of projects.*
Writing custom workspace rules
Built-in lint rules cover import boundaries. Some invariants live outside source files, and that is where a custom rule earns its maintenance cost. Typical cases:
- Required tags: every project carries a
scope:and atype:tag. Without them,depConstraintssilently skips the project. - Banned dependency directions: for example,
paymentsmay never depend onmarketing, even through a shared library. - Required targets: every deployable project defines
testandlint, so CI can't skip a project that never had them. - Consistent output paths: outputs land in predictable locations so that caching and artifact collection behave the same everywhere.
Keep each rule small, deterministic and fast. A rule that checks one invariant gives one clear failure. A rule that depends on network calls or timestamps produces flaky results, and engineers learn to rerun it until it passes. If a rule adds noticeable time to every pull request, people will push to remove it.
A practical pattern is a script that runs nx graph --file=graph.json and then reads the nodes and dependencies from that file. It walks the projects, checks the invariant, and prints one line per violation. Each line names the project, its owning team, the rule ID and the fix, such as "add tag scope:billing in project.json." That lets the right team act without asking the platform group.
Treat these scripts as production code. Give them fixture graphs and tests. Expect them to break when an Nx upgrade changes the graph format or when the repository is restructured. Give every rule a severity switch so you can downgrade it to a warning in one commit instead of reverting it under pressure.
// check-required-tags.mjs: run after `nx graph --file=graph.json`
// Rule: every project must carry a scope:* and a type:* tag.
import { readFileSync } from 'node:fs';
const RULE_ID = 'required-tags';
// Severity switch: set to 'warn' in one commit to make the rule non-blocking.
const SEVERITY = process.env.RULE_REQUIRED_TAGS_SEVERITY ?? 'error';
// Ownership comes from your own mapping (for example, generated from CODEOWNERS).
// Nx does not store team ownership in the graph.
const OWNERS = { 'apps/billing': 'team-billing', 'libs/marketing': 'team-growth' };
const ownerFor = (root) =>
Object.entries(OWNERS).find(([prefix]) => root.startsWith(prefix))?.[1] ?? 'unowned';
const file = process.argv[2] ?? 'graph.json';
const { graph } = JSON.parse(readFileSync(file, 'utf8'));
const violations = [];
// Sort the projects so the output order is deterministic.
for (const name of Object.keys(graph.nodes).sort()) {
const { root = '', tags = [] } = graph.nodes[name].data ?? {};
for (const prefix of ['scope:', 'type:']) {
if (!tags.some((t) => t.startsWith(prefix))) {
violations.push(
`[${RULE_ID}] ${name} (owner: ${ownerFor(root)}): missing ${prefix}* tag. ` +
`Fix: add a "${prefix}<value>" entry to "tags" in ${root}/project.json`
);
}
}
}
violations.forEach((v) => console.log(`${SEVERITY.toUpperCase()} ${v}`));
// Fail the build only when the rule is blocking.
process.exit(violations.length > 0 && SEVERITY === 'error' ? 1 : 0);
A Node script reads the project graph exported by nx graph --file=graph.json, checks that every project has scope: and type: tags, and prints one line per violation with the project, owner, rule ID and fix. An environment variable switches the rule from blocking to warn-only.
Rolling out rules like a migration
Turning a new rule on as a hard failure on day one blocks every team in the workspace at the same moment, including teams whose code was compliant until the rule was written. Their pull requests fail for violations they did not introduce and cannot fix quickly, so the usual response is a blanket disable, and the rule loses credibility before it has done anything useful. A safer approach is to treat each rule as a migration with distinct stages.
| Stage | CI behaviour | Exit criterion |
|---|---|---|
| Report-only | Runs, never fails | Violation count is stable and attributed to owners |
| Baseline | Fails only on violations not in the recorded baseline | Baseline committed and reviewed |
| Burn-down | Same as baseline; baseline shrinks per team | Remaining entries have an owner and a date |
| Enforced | Any violation fails | Baseline empty or reduced to allowlisted exceptions |
The baseline is a committed file of known violations. CI compares current results against it, so new code is held to the rule while old debt is paid down on each team's schedule. Fixing a violation removes its entry. Any pull request that adds an entry has to justify it during review.
Run checks only against affected projects, using the same project-graph computation that nx affected relies on. Check time then grows with the size of the change and not with the size of the repository.
Remaining exceptions go into a single allowlist. Each entry records an owner, a reason, and a review date. Unlike inline disable comments scattered through the code, the allowlist can be read in one place, searched, and audited when entries go stale.
// check-baseline.ts: fail CI only on new violations; old debt stays in the baseline.
// Assumes your rule runner (scoped to affected projects) writes violations.json.
import { readFileSync } from 'node:fs';
type Violation = { rule: string; project: string; file: string };
type AllowEntry = Violation & { owner: string; reason: string; reviewBy: string };
const load = <T>(path: string): T => JSON.parse(readFileSync(path, 'utf8'));
const key = (v: Violation) => `${v.rule}|${v.project}|${v.file}`;
const current = load<Violation[]>('violations.json');
const baseline = new Set(load<Violation[]>('conformance-baseline.json').map(key));
const allowlist = load<AllowEntry[]>('conformance-allowlist.json');
const allowed = new Set(allowlist.map(key));
// New code must comply: anything missing from both files fails the build.
const introduced = current.filter((v) => !baseline.has(key(v)) && !allowed.has(key(v)));
// Baseline entries that no longer occur were fixed. Remove them so the baseline only shrinks.
const currentKeys = new Set(current.map(key));
const fixed = [...baseline].filter((k) => !currentKeys.has(k));
// Allowlist entries past their review date have to be re-justified by their owner.
const today = new Date().toISOString().slice(0, 10);
const stale = allowlist.filter((e) => e.reviewBy < today);
fixed.forEach((k) => console.log(`fixed, remove from baseline: ${k}`));
stale.forEach((e) => console.error(`review overdue (${e.owner}): ${key(e)}, ${e.reason}`));
introduced.forEach((v) => console.error(`new violation: ${key(v)}`));
if (introduced.length > 0 || stale.length > 0) process.exit(1);
A CI gate that fails only on violations missing from the committed baseline and allowlist. It lists fixed baseline entries for removal and flags allowlist exceptions whose review date has passed.
When to avoid it and what can go wrong
That auditability only helps if someone reads it. Heavy enforcement also isn't always worth the overhead.
When to hold back
A workspace with a handful of projects and one team gets most of the value from tags and depConstraints. Conformance adds little there. If your organization can't take on a paid plan or a dependency on a hosted service, plain lint rules in CI are the honest ceiling. Most important, if nobody owns the rule set, don't start. Unowned rules go stale and get ignored.
How it goes wrong
Over-enforcement breeds bypass culture. Suppressions pile up, a catch-all shared library absorbs everything that wouldn't pass review, and tags drift toward permissive values like scope:any. Baselines meant to be temporary become permanent policy once review dates pass unenforced.
Rules also miss things. The project graph can't see dynamic imports with computed paths, generated code that isn't checked in, or dependencies reached through HTTP calls, queues, or runtime configuration. A passing check means "nothing Nx can see," not "nothing."
A centralized platform team that must approve every exception becomes the bottleneck the rules were meant to remove.
Treat the rule set as code
Give the rule set a named owner and keep its configuration versioned and reviewed like any other change. Publish a lightweight process for proposing a rule and for contesting one. Announce enforcement dates in advance so teams can schedule cleanup instead of discovering a red build.
Alternatives and impact on cost and engineering time
Each option shifts cost between licensing spend and the engineering time you put into maintaining enforcement yourself.
| Option | Licensing cost | Maintenance burden | Coverage | Hosted-service dependency |
|---|---|---|---|---|
Tags + @nx/enforce-module-boundaries |
None beyond open-source Nx | Low: tag taxonomy and depConstraints |
Import edges between projects only | None |
| Custom graph scripts | None | High: you own the parsing, CI wiring, and fixes when Nx internals change | Whatever you build | None |
| Nx enterprise conformance | Commercial license | Medium: rule code and configuration | Arbitrary workspace-level checks | Some features rely on Nx's hosted platform, depending on your setup |
| CODEOWNERS review | None | Low setup, ongoing reviewer time | Only what reviewers notice | Your code host |
Automated rules save time in two places. They catch architectural drift before review, so reviewers do not spend a review cycle on a forbidden import. They also keep project configuration uniform, for example declared inputs and outputs. Without that, cache hits become unreliable and teams fall back to slow uncached builds.
Rules also cost time. Someone has to keep rules current as the architecture changes, and teams have to burn down existing violations before enforcement starts.
Illustrative estimate (assumed numbers, not a benchmark):
Net hours per month = (violations caught × hours saved each) − (rule maintenance + burn-down ÷ amortization months)
Assumed inputs:
- 40 violations caught per month
- 0.5 hours saved per violation
- 6 hours of rule maintenance per month
- 60 hours of one-time burn-down, amortized over 12 months
Calculation: (40 × 0.5) − (6 + 60 ÷ 12) = 20 − 11 = 9 hours saved per month.
If few violations reach review, the result can go negative. Run the calculation with your own numbers before paying for a license.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to nx conformance rules, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Reviewers see the exact diff and the list of affected services up front, so approval is a decision rather than an investigation.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- Per-service approve/reject means a bad shared value can be refused where it would break something, before it is live.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.