Generators vs executors: what each one does
A generator changes the workspace once. You run it with nx generate, it creates or modifies files, updates configuration such as project.json or tsconfig paths, and then exits. An executor implements a target that runs repeatedly, such as build, test or deploy, against an existing project.
| Aspect | Generator | Executor |
|---|---|---|
| Purpose | Create or modify files and config | Run a task for a project |
| When it runs | Once, at creation or migration time | Every time the target is invoked |
| Inputs | Options defined in its schema | Target options plus project files |
| Outputs | File changes in the workspace | Build artefacts, test results, exit code |
| Typical examples | New service, new library, add a Dockerfile | Build an image, run integration tests, deploy |
Picking the wrong one produces awkward plugins. An executor that rewrites source files on every run fights the cache, because its outputs change its own inputs. A generator that tries to orchestrate builds runs only once and goes stale.
Encoding conventions in code beats a wiki page or a copy-paste template. Documentation drifts and nothing checks it. Copied templates diverge after the first edit. A generator applies the current convention every time a project is created, including tags, targets and folder layout. Nx then builds a project and task graph from that configuration, so the conventions are visible. Tag-based module boundary rules can also enforce them.
Platform teams that support many backend services benefit most. The approach fits when new services are created regularly and should share the same structure, build steps and deployment shape.
How a local workspace plugin fits into an Nx repo
The usual starting point is the @nx/plugin package. Its plugin generator creates a library inside the workspace, typically under tools/ or libs/. Its generator and executor generators then add individual capabilities to that library. Each generator is registered by name in the plugin's generators.json, which points to its implementation and its JSON schema. Executors are registered the same way in executors.json. The plugin's package.json references both files, and that is how Nx discovers them. Because the plugin is a regular workspace project, engineers invoke it with the same syntax as published plugins, for example nx generate @acme/platform:service orders-api. Nothing needs to be published to a registry.
The flow after that is linear:
- The generator writes source files and a
project.json. - The
project.jsondeclares targets such asbuild,testandserve, and those targets reference the plugin's own executors. - Nx reads that configuration into the project graph.
- When someone runs a target, Nx expands it into a task graph.
Caching applies at the task level. Nx hashes each task's declared inputs and replays stored outputs on a hit. This means the generator decides, at creation time, which inputs and outputs every new service declares, and those declarations determine whether its builds and tests can be cached at all.
That is why the plugin needs an owner, a changelog and review rules, the same as any other internal product. Every service it creates depends on its decisions. If the plugin is abandoned, older services keep the old conventions while newer ones follow the new conventions.
+--------------------------------------------------------------+
| tools/platform (local Nx plugin: owner, changelog, review) |
| package.json |
| +--> generators.json --> service generator + schema |
| +--> executors.json --> build/test executors + schema |
+--------------------------------------------------------------+
| ^
| nx generate @acme/platform:service | targets use
v orders-api | plugin executors
+----------------------------+ |
| apps/orders-api | |
| src/... | |
| project.json |----------------+
| targets: build, test, |
| serve -> plugin executors|
| inputs / outputs |
+----------------------------+
|
| Nx reads project configuration
v
+----------------------------+
| Project graph |
+----------------------------+
|
| nx run orders-api:build
v
+----------------------------+ hash inputs +-------------+
| Task graph |------------------->| Task cache |
| orders-api:build |<-------------------| (outputs) |
+----------------------------+ replay on hit +-------------+
A local plugin registers generators and executors through its package.json. The generator writes project.json targets that call those executors. Nx turns them into the project graph and task graph, and caching works per task based on the inputs and outputs the generator declared.
Building a service generator: schema, templates and the Tree
A generator has three parts. The schema.json declares options using JSON Schema: types, required fields, enums, defaults, and x-prompt entries for interactive use. Nx validates input against the schema before your code runs, so the generator never has to handle a missing name or an invalid enum value. Template files sit in a files/ directory. Their contents use EJS substitutions such as <%= name %>, and path segments like __name__ are renamed on output. The generator function receives a Tree, which is a virtual file system. Calls such as generateFiles, addProjectConfiguration and updateJson change the Tree in memory, and Nx writes everything to disk only after the function returns. Because changes are staged, nx g ... --dry-run can list every file that would be created or modified without touching the workspace.
Build on the official plugins instead of reproducing their output. Call @nx/node's application generator from your own generator so it scaffolds the base project, then add your house conventions on top: shared lint config, test setup, a Dockerfile, CI targets in project.json, and project tags. End with formatFiles.
Tags have the largest effect. The @nx/enforce-module-boundaries lint rule matches dependency constraints against tags. If the generator stamps every service with something like type:service and scope:<domain>, each new project is covered by the boundary rules from its first commit, and nobody has to remember to add it.
Keep the generator opinionated. A small set of options, such as name, domain and maybe a transport, is easier to maintain than dozens of flags. Each extra flag doubles the variants you have to test and makes services in the repo less consistent with each other.
import {
Tree,
formatFiles,
generateFiles,
joinPathFragments,
names,
readProjectConfiguration,
updateProjectConfiguration,
} from '@nx/devkit';
import { applicationGenerator } from '@nx/node';
// Deliberately small option set; schema.json validates both fields before this runs.
interface ServiceSchema {
name: string;
domain: string;
}
export default async function serviceGenerator(tree: Tree, options: ServiceSchema) {
const directory = `apps/${options.domain}/${options.name}`;
// Let the official plugin scaffold the base Node app instead of copying its output.
const installTask = await applicationGenerator(tree, { name: options.name, directory });
const project = readProjectConfiguration(tree, options.name);
// House conventions: Dockerfile, shared lint/test config, etc. from ./files (EJS templates).
generateFiles(tree, joinPathFragments(__dirname, 'files'), project.root, {
...names(options.name),
domain: options.domain,
tmpl: '', // strips the __tmpl__ suffix from template file names
});
// Tags make @nx/enforce-module-boundaries apply to this project from its first commit.
project.tags = [...(project.tags ?? []), 'type:service', `scope:${options.domain}`];
// A CI target every service gets, so pipelines can run `nx affected -t docker-build`.
project.targets = {
...project.targets,
'docker-build': {
executor: 'nx:run-commands',
options: { command: `docker build -f ${project.root}/Dockerfile -t ${options.name} .` },
},
};
updateProjectConfiguration(tree, options.name, project);
// All changes so far live only in the Tree; Nx writes them after we return (or lists them on --dry-run).
await formatFiles(tree);
return installTask;
}
A service generator that composes @nx/node's application generator, renders house templates onto the Tree, and stamps tags and a Docker CI target. Because every change is staged in the Tree, nx g with --dry-run can preview the result.
Writing executors without breaking the cache
An executor is a schema plus a function. The schema (schema.json) declares and validates the options a target accepts. The function receives those options and an ExecutorContext, which holds the workspace root, project name and configuration. It returns { success: boolean }, either from a promise or from an async iterable for long-running tasks. Nx treats the result as the task outcome, so an executor that swallows an error and reports success also tells the cache that a broken build is valid.
Choosing an executor
| Option | Use when | Cost |
|---|---|---|
nx:run-commands |
A shell command already does the job | Low, but caching depends entirely on your inputs/outputs |
| Existing plugin executor | A maintained plugin covers your tool | Low upkeep, follows upstream conventions |
| Custom executor | You need logic, validation or shared defaults across many projects | You own testing and upgrades |
Start at the top of the table and move down only when the option above it doesn't meet the need.
Declaring inputs and outputs
Nx computes a cache key from a task's declared inputs and restores only its declared outputs. As an example, a build target can set "cache": true, "inputs": ["production", "^production", { "env": "BUILD_TARGET" }] and "outputs": ["{workspaceRoot}/dist/{projectRoot}"].
Anything the task reads that is missing from that list produces stale cache hits. Common examples are an environment variable, a config file outside the project, or the current time. Anything it writes outside outputs is lost on a cache hit.
Keep executors deterministic: no timestamps in artefacts, no network fetches, and pinned tool versions. Caching that hides real failures is worse than no caching, because it makes a passing CI run meaningless.
// tools/executors/tsc-build/executor.ts
import type { ExecutorContext } from '@nx/devkit';
import { spawnSync } from 'child_process';
import * as path from 'path';
// Options validated by this executor's schema.json
interface TscBuildOptions {
tsConfig: string; // should also be covered by the target's inputs
outputPath: string; // must match the target's declared outputs
}
export default async function tscBuild(
options: TscBuildOptions,
context: ExecutorContext,
): Promise<{ success: boolean }> {
if (!context.projectName) {
return { success: false };
}
// Write only under outputPath, so a cache hit restores everything this task produced
const outDir = path.join(context.root, options.outputPath);
// Use the workspace-pinned compiler. Do not use timestamps, network
// fetches or undeclared env vars.
const result = spawnSync(
'npx',
['tsc', '-p', options.tsConfig, '--outDir', outDir],
{ cwd: context.root, stdio: 'inherit' },
);
// Report the real exit status. Returning success: true after a failure
// would cache a broken build as valid.
return { success: result.status === 0 };
}
A minimal custom executor that writes only to its declared output path, uses the workspace-pinned compiler, and reports the compiler's real exit status as the task result so that failures are never cached as successes.
Testing plugins and evolving conventions over time
Unit-testing generators against an empty Tree
Generators are functions over a virtual file system, so you can test them without touching disk. @nx/devkit/testing provides createTreeWithEmptyWorkspace(), which returns an in-memory Tree with minimal workspace files. Run your generator against it, then assert on the result:
tree.exists()confirms expected files were created.tree.read(path, 'utf-8')lets you check file contents.readProjectConfiguration(tree, name)lets you check targets, tags and options.
Keep assertions specific. A broad snapshot of the whole tree approves every change without making anyone think about it. Run these tests in CI like any other suite. A change to a template or default then shows up as a failing or updated assertion in a pull request, where it gets reviewed instead of spreading silently through new services.
Bringing existing projects forward
A generator only shapes code created after the change. Services generated last quarter keep last quarter's conventions. When a convention changes, pair the generator update with a migration-style generator that rewrites existing projects. Published plugins can register these in migrations.json so nx migrate picks them up. A local plugin can ship a one-off update generator that you run across the affected projects. Test migrations the same way: seed the Tree with the old layout and assert on the new one.
Versioning deliberately
Treat plugin releases as API changes and record what each version alters. Defaults that touch security or operations need explicit review whenever they change, even if the tests pass. Examples include container base images, exposed ports, logging levels and auth middleware. Passing tests show the generator did what it was told. They do not show that the new default is safe.
import { Tree, readProjectConfiguration } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { serviceGenerator } from './generator';
describe('service generator', () => {
let tree: Tree;
beforeEach(() => {
// Fresh in-memory workspace per test; nothing touches disk.
tree = createTreeWithEmptyWorkspace();
});
it('registers the project with the expected tags and targets', async () => {
await serviceGenerator(tree, { name: 'billing-api' });
const config = readProjectConfiguration(tree, 'billing-api');
// Assert on specific conventions so changes show up in review.
expect(config.tags).toContain('type:service');
expect(config.targets?.build).toBeDefined();
});
it('writes the Dockerfile with the approved base image and port', async () => {
await serviceGenerator(tree, { name: 'billing-api' });
const path = 'billing-api/Dockerfile';
expect(tree.exists(path)).toBe(true);
const dockerfile = tree.read(path, 'utf-8') ?? '';
// Security and ops defaults: changing these should fail this test
// and force an explicit reviewer decision in the pull request.
expect(dockerfile).toContain('FROM node:20-alpine');
expect(dockerfile).toContain('EXPOSE 3000');
});
});
A Jest test that runs a local generator against an empty in-memory Tree and asserts on project configuration and generated file contents, so security and ops defaults are pinned and reviewed. Paths assume the generator's default directory; adjust to match yours.
When to avoid Nx generators and executors, and the alternatives
Generators and executors are software, and they need an owner. Without one, a plugin falls behind the framework versions it targets. Engineers then hit a broken generator once, stop trusting it, and go back to copying the nearest service. A generator also only shapes a project at creation time. After that, each service drifts unless you write migrations or lint rules to pull it back.
Abstraction has a cost too. A generator with a dozen flags and conditional templates can be harder to read than the twenty files it produces. Custom executors add another layer when a build fails: engineers have to debug your wrapper before they reach the real tool. Executors also depend on Nx plugin APIs, which can change between major versions, so each upgrade becomes a maintenance task. The more you encode in Nx, the more it would cost to move off it later.
For Go, Java or Python backends, check how mature and actively maintained the relevant plugins are before you commit. The JavaScript and TypeScript experience is the most developed.
Alternatives
| Option | Good for | Main weakness |
|---|---|---|
| Written docs | Small teams, conventions that change often | Nothing enforces them |
| Template repository | Polyrepos, one-off service creation | Copies drift immediately |
| Standalone scaffolding tool | Language-agnostic templates | No task graph or cache |
| Scripts or Makefiles | Simple, transparent build steps | No caching or affected detection |
| Shared CI templates | Enforcing pipeline stages | Checks happen late, not locally |
Impact on development cost and engineering time
A local plugin is code your team owns, so treat it as a recurring cost rather than a one-off. The savings come from work that is avoided many times. The costs come from upkeep that never fully goes away.
| Cost of owning the plugin | Time it saves |
|---|---|
| Building generators, executors and schemas | Manual setup for each new service |
| Tests for generated output and executor behaviour | Review comments about structure and config drift |
| Fixes after Nx major upgrades | Boundary violations caught by tags at lint time |
| Update generators when conventions change | Debugging cache misses or stale cached results from hand-written targets |
Illustrative estimate (assumed numbers, not a benchmark)
Net hours per quarter = S × (M − G) + R − (U + T)
Assumed inputs:
- S = 6 new services per quarter
- M = 8 hours of manual setup per service
- G = 1 hour of setup using the generator
- R = 10 hours of review churn avoided
- U = 12 hours of upkeep (Nx upgrades, update generators)
- T = 4 hours of test maintenance
Result: 6 × 7 + 10 − 16 = 36 hours saved per quarter. With an assumed initial build cost of 40 hours, the plugin pays for itself in just over one quarter.
Now set S = 1. The net drops to 1 hour per quarter, and payback never realistically arrives. S and U drive the outcome, so measure those two first.
Readiness checklist
- You create new services or libraries at least monthly.
- Your conventions are stable enough to write down and agree on.
- A named owner has time budgeted for plugin maintenance.
- You apply Nx upgrades regularly instead of skipping major versions.
- Generator and executor tests run in CI.
- Project tags and boundary rules exist or are planned.
- You will write update generators rather than let older projects drift.
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 generators, 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.