What Nx conformance rules are and where they fit
Nx conformance rules are a workspace-level governance layer. They sit alongside two tools most teams already use. Generators create projects that start out compliant. Lint keeps the code inside each project compliant. Conformance checks the workspace as a whole and catches drift that neither of the other two can see.
The boundary is about scope. A lint rule runs against the files of one project. It can flag a banned import or an unhandled promise, but it cannot see other projects. A conformance rule runs against the project graph, project configuration and the relationships between projects. It can answer questions such as "does every deployable service declare an owner?" and "does any library depend on an application?" Generators fall short for a different reason: they only act once, at creation time. Nothing stops a project from changing after it has been generated.
| Backend standard | Right tool | Why |
|---|---|---|
No console.log in request handlers |
Lint | File-level pattern |
| Every deployable service has a health-check target | Conformance | Inspects project configuration |
| Libraries never depend on applications | Conformance | Inspects graph edges |
| New services start with a Dockerfile and test target | Generator | Scaffolding at creation |
| Service tags match an approved list | Conformance | Cross-project metadata |
Generators → create projects (one-time) Lint → source files inside each project Conformance → project configuration + project graph
Conformance is not part of core Nx. It ships in Nx's paid Powerpack/enterprise offering. The package name, licensing terms and configuration keys have changed over time, so confirm all three in the documentation for your Nx version before you adopt it.
TOOLS WORKSPACE LAYERS
+--------------+ +--------------------------------------------+
| Conformance | checks | PROJECT GRAPH + PROJECT CONFIGURATION |
| (Powerpack / |------------>| api-svc ----> auth-lib ----> db-lib |
| enterprise) | whole graph | orders-svc ----------------> db-lib |
+--------------+ | targets, tags, owners, dependency edges |
+--------------------------------------------+
| contains | contains
v v
+--------------+ +-------------------+ +----------------------+
| Lint | checks | api-svc/src/** | | db-lib/src/** |
| (each |------------>| handlers, imports | | queries, imports |
| project) | one project | promises | | promises |
+--------------+ at a time +-------------------+ +----------------------+
+--------------+ +--------------------------------------------+
| Generators | creates | New project: config + targets + src files |
| (one-time) |------------>| compliant at creation; added to the graph, |
| | at creation | then free to drift afterwards |
+--------------+ +--------------------------------------------+
Generators scaffold compliant projects once, lint checks the source files inside each project, and conformance checks project configuration and dependency edges across the whole workspace graph.
Who should use conformance and which backend standards to encode
Conformance pays off in workspaces where dozens of backend services and libraries share one repository and a platform team is accountable for how they build, deploy and depend on each other. At that scale, many important standards concern the shape of the workspace rather than the code itself. A linter inspects source files, and code review catches what a reviewer happens to notice. Neither reliably confirms that a project has an owner or a deploy target, and neither gives you a single report across every project.
Good candidates
- Every deployable service has an owner. On-call routing, security patching and dependency upgrades all stall when nobody owns a service.
- Deployable apps carry required type and scope tags. Tag-based dependency constraints, and any automation that filters projects by tag, are only as good as the tags themselves.
- Services declare build, test and deploy or container targets. CI pipelines and affected-project commands assume these targets exist. A missing one usually fails silently, because the project is simply skipped.
- Domain libraries do not depend on app projects. This keeps domain logic reusable and the project graph acyclic in the direction you intend.
- Shared infrastructure libraries do not import service-specific code. Once they do, a change to one service can force rebuilds and redeploys of everything downstream.
Poor candidates
Formatting, code style, type errors and per-file ownership are poor candidates. Formatters, ESLint, the compiler and CODEOWNERS already handle these well, and duplicating them adds noise without adding coverage.
Small workspaces, or teams without a clear platform owner to maintain the rules, may not need conformance at all.
How the rule model works
A conformance rule is a function that runs once against the whole workspace rather than once per file. It receives the workspace context: the list of projects, each project's resolved configuration (targets, tags, root, metadata), and the project graph with its dependency edges. That scope is the main difference from a lint rule. A lint rule sees one file. A conformance rule can ask cross-cutting questions, such as "does every project tagged type:service define a docker-build target and avoid depending on another service directly?"
The rule returns violations, and each violation is attributed to a specific project and carries a message. Because violations are scoped to projects, owners can see exactly what they need to fix, and you can exclude individual projects without weakening the rule for everyone else.
Configuration lives in nx.json under a conformance key. That key holds a rules array. Each entry names the rule (a package rule or a local path), sets a status, and can narrow which projects it applies to, including exclusion patterns.
The status field is where rollout control comes from:
- Report-only (
evaluated): the rule surfaces violations but does not fail the run. enforced: violations fail the run.
You run rules with nx conformance, and use a check-style variant in CI that reports without applying fixes.
Illustrative only: the key names, status values and command names above describe the general shape. Verify them against the Nx Powerpack documentation for your installed version before you copy anything.
# ILLUSTRATIVE ONLY: verify keys, status values and commands
# against the Nx Powerpack docs for your installed version.
# --- nx.json (excerpt) ---
{
"conformance": {
"rules": [
{
// Built-in package rule. Violations fail the run.
"rule": "@nx/conformance/enforce-project-boundaries",
"status": "enforced"
},
{
// Local workspace rule, referenced by path.
// Report-only while teams fix existing violations.
"rule": "./tools/conformance/service-has-docker-build",
"status": "evaluated",
// Apply to every project except one legacy service.
// That service is excluded from this rule only.
"projects": ["*", "!legacy-billing-api"]
}
]
}
}
# --- shell ---
# Run all configured rules locally.
nx conformance
# CI: report violations without applying fixes.
# Only rules with status "enforced" fail the job.
nx conformance:check
An illustrative nx.json conformance block with one enforced rule and one report-only (evaluated) local rule that excludes a single project, followed by the general local and CI invocations. The // comments are annotations for readers; strip them unless your Nx version accepts comments in nx.json.
Implementing a custom rule for deployable backend services
Take a concrete standard: every project tagged type:backend-service must define either a deploy or a health-check target, and must declare an owner in its project metadata. Both checks need only data the project graph already holds, which is why this standard suits a conformance rule.
Reading the graph once
The rule receives the resolved workspace context, including each project's configuration as Nx computed it. Inferred targets from plugins are already merged in, so a target added by a plugin counts the same as one written by hand. Iterate the project nodes a single time. For each node, check whether its tags include type:backend-service, and skip it immediately if they don't. For matching projects, look up the two target names in the target map and read the owner field from metadata.
Avoid opening project.json files or walking directories inside the rule. The graph has already resolved that data, and reading files separately can disagree with it. It also slows every run and makes results depend on filesystem state. One pass with no repeated traversals keeps the rule linear in project count and gives the same output for the same graph.
Violation messages that fix themselves
Each violation should name three things: the project, what is missing, and the fix. For example: "payments-api is tagged type:backend-service but defines neither a deploy nor a health-check target. Add one, or remove the tag if this project is not deployed." Emit one violation per missing item rather than one combined message, so partial fixes show up as progress.
The conformance rule API has changed between Nx releases and may change again. Pin the Nx version, keep the rule logic in plain functions that are separate from the API wrapper, and re-run the rule's tests on every upgrade.
import type { ProjectGraph } from '@nx/devkit';
// Plain rule logic, kept separate from the conformance API wrapper so it
// survives Nx upgrades and can be unit-tested against a fixture graph.
export interface Violation {
project: string;
message: string;
}
const TAG = 'type:backend-service';
const RUN_TARGETS = ['deploy', 'health-check'];
export function checkBackendServices(graph: ProjectGraph): Violation[] {
const violations: Violation[] = [];
// Single pass over resolved nodes: inferred targets are already merged,
// and no project.json or directory is read from disk.
for (const node of Object.values(graph.nodes)) {
const { name, data } = node;
if (!data.tags?.includes(TAG)) continue; // skip non-matching projects early
const targets = data.targets ?? {};
if (!RUN_TARGETS.some((t) => t in targets)) {
violations.push({
project: name,
message: `${name} is tagged ${TAG} but defines neither a deploy nor a health-check target. Add one, or remove the tag if this project is not deployed.`,
});
}
// 'owner' is this workspace's own metadata convention, not an Nx built-in.
const owner = (data.metadata as { owner?: string } | undefined)?.owner;
if (!owner) {
violations.push({
project: name,
message: `${name} is tagged ${TAG} but has no owner. Set metadata.owner in its project configuration.`,
});
}
}
// Sort by project name so output is deterministic for the same graph.
return violations.sort((a, b) => a.project.localeCompare(b.project));
}
The rule's core logic as a plain function that walks the project graph once, filters by tag, and emits one actionable violation per missing target or owner, ready to be called from whatever conformance rule wrapper your pinned Nx version provides.
Rolling out rules like a database migration
Treat a new rule like a schema change: add it in report-only mode, measure, migrate, then enforce. Set the rule's status to evaluated so it reports violations without failing the run. Record a baseline count of violations per project, group the results by owning team, and give each team a deadline. Change the status to enforced only after that count reaches zero or the remaining violations have been deliberately exempted.
Enforcing a rule that already has hundreds of violations rarely works. Every unrelated pull request starts failing, and the people blocked by it did not cause the problem. Teams then respond in one of two ways: they add blanket exclusions until CI passes, or someone reverts the rule. In both cases the rule stops meaning anything.
Make every exemption deliberate. Each excluded project needs a written reason and a named owner, and the exclusion list should be reviewed on a fixed schedule. Without that review, temporary exemptions become permanent.
Watch for rules being gamed. A check that requires a test or lint target can be satisfied by an empty target that does nothing. Where you can, have the rule inspect the target's executor or options, not only check that the target exists.
In CI, run conformance as its own required check, separate from affected-based jobs. Conformance evaluates the entire workspace, and a tag or configuration change in one project can create violations in projects that the affected graph never touches. The CI step runs the check command against the full workspace on every pull request and fails the build on any violation from an enforced rule.
# .github/workflows/ci.yml (excerpt)
# Conformance runs as its own required job, separate from the affected-based job,
# so a tag or config change in one project can't hide violations elsewhere.
name: CI
on:
pull_request:
jobs:
affected:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # affected needs history to compute the base
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
- run: npx nx affected -t lint test build --base=origin/main --head=HEAD
conformance:
# Mark this job as a required status check in branch protection.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- run: npm ci
# Evaluates every project in the workspace, not just affected ones.
# Rules with status "enforced" fail this step on any violation;
# rules with status "evaluated" only report, which is how you
# collect the per-project baseline during rollout.
# conformance:check does not apply fixes, so CI never rewrites files.
- run: npx nx conformance:check
A GitHub Actions workflow that runs nx conformance:check across the whole workspace as a separate required job, alongside the affected-based lint, test and build job.
When to avoid conformance, and the alternatives
That full-workspace run is also where the costs show up, so weigh them before adopting conformance.
Conformance is part of Nx Powerpack and needs a license. Rules written against its API are not portable. If you drop the license, you rewrite them. If a constraint is already expressed as a tag rule in the module boundary lint rule, keep it there. Two sources of truth for one constraint will eventually disagree.
Rules also freeze today's architecture. A rule that requires every service to have a migrations folder will block a legitimate move to a shared migration runner. It stays in the way until someone edits the rule or adds an exclusion. If a central platform team owns every edit and exclusion, product teams queue behind it. In large workspaces, checks that read many files on every pull request add noticeable CI time.
The quieter risk is false confidence. Conformance checks metadata and structure, not behaviour. A service with a correct health-check target can still have a broken health check.
| Alternative | Enforces | Strength | Weakness |
|---|---|---|---|
| Module boundary lint rule | Import constraints between tagged projects | Free, runs in the editor | Limited to dependencies |
| Custom script over the project graph JSON | Anything visible in the graph | No license, fully portable | You maintain the reporting and exclusions |
| Generators alone | Correct structure at creation | Zero CI cost | Nothing stops later drift |
| Code review and CODEOWNERS | Judgement-based standards | Catches behavioural issues | Inconsistent and depends on reviewers |
Impact on cost and engineering time
Conformance has two recurring costs: the Nx Powerpack license and the engineering time needed to keep custom rules working across Nx upgrades. Rules read project configuration, so changes to the project graph or the rule APIs can break them. Budget time to re-test every rule on each major upgrade.
The savings come from three places. Reviewers write fewer comments about folder layout and missing targets. Service metadata, such as owner, tier and deploy target, drifts less. New engineers onboard through generators instead of copying an existing service and its mistakes.
The generator determines whether a rule saves time or costs it. If every rule ships alongside an updated backend service generator, new projects are conformant the moment they are created. The rule then only catches drift in older or hand-edited projects. A rule without a matching generator blocks people who are trying to ship new work, and those engineers will ask for exemptions.
What to track
- Violation count per rule over time. This count should trend toward zero. If it stays flat, the rule is wrong or the generator is.
- Active exclusions. A growing list means the rule no longer reflects how teams actually build services.
- Exemption requests per month. This is the most direct measure of friction.
Illustrative estimate (assumed numbers, not a benchmark)
Monthly net = (structural review comments avoided × minutes per comment) + (drift fixes avoided × minutes per fix) − rule maintenance minutes − license cost expressed in engineer-minutes.
Assume:
- 60 comments avoided × 10 min = 600 min
- 8 drift fixes avoided × 45 min = 360 min
- 240 min of maintenance
- License equivalent to 300 min
Net: 600 + 360 − 240 − 300 = 420 minutes saved per month.
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.