What is an Nx local generator and why not just copy an existing service?

An Nx local generator is a generator that lives in a local Nx plugin, which is a project inside the same workspace as the services it creates. It is not published to a registry. Engineers run it like any official generator, and it emits a new service that already follows your conventions. Because it sits in the monorepo, it changes through the same pull requests, code owners, and CI as the services themselves.

The usual alternative is to copy the newest service and rename things. That works once, but it also copies whatever that service has picked up over time: dependencies pinned for an old reason, leftover business code nobody fully removes, and config tweaks that only made sense for that service. Each copy starts from a slightly different point, so services drift apart.

Concern Copying an existing service Local generator
Source of the baseline Whichever service was copied Templates agreed by the platform team
Leftover business code Removed by hand, often incompletely Never present
Dependencies Inherited, possibly stale Declared in one place
Changes to the baseline Spread informally, if at all Reviewed and tested like other code

A generator turns the agreed baseline into code that can be reviewed and tested. This pays off most for platform teams and backend leads in a monorepo that creates new services regularly. The time saved is concrete. Nobody spends the first day of a new service cleaning up after a copy, and nobody has to set up logging, health checks, the Dockerfile, and CI targets again for each service.

How does an Nx generator work inside a local plugin?

A local plugin is an ordinary project in your workspace, usually generated with the official @nx/plugin generators, that ships one or more generators alongside your services. Four pieces matter.

The moving parts

  • generators.json: the manifest. The plugin's package.json points to it, and it maps each generator name to its implementation and schema. Nx reads this file to resolve a command such as nx g @acme/tooling:service.
  • schema.json: a JSON Schema that describes the generator's options, including types, defaults, required fields, and interactive prompts. A matching TypeScript interface keeps the implementation typed against the same contract.
  • The implementation function: a default export that receives a Tree and the parsed options.
  • The files/ directory: template files whose contents and file names are interpolated with your options. For example, __name__ in a path becomes the service name.

What happens on invocation

Nx finds the generator through the manifest and validates the supplied options against the schema. It prompts for anything missing. If an option is invalid, the run stops before any code executes, so the implementation can assume its inputs are valid.

The function then works on the Tree, a virtual file system layered over the workspace. Calls like generateFiles and JSON updates only record changes in memory. Nx writes those changes to disk after the function returns, and only if it succeeds. This is why a dry run can show every create, update, and delete without touching anything. It also means a failed generator leaves no half-written service behind.

The function can return a callback that runs after the files are written. Side effects that need real files belong there, such as installing dependencies.

// tools/acme-tooling/src/generators/service/generator.ts
import {
  Tree,
  formatFiles,
  generateFiles,
  installPackagesTask,
  joinPathFragments,
  names,
} from '@nx/devkit';
import { ServiceGeneratorSchema } from './schema'; // mirrors schema.json

export default async function serviceGenerator(
  tree: Tree,
  options: ServiceGeneratorSchema
) {
  // Nx has already validated `options` against schema.json and prompted
  // for anything missing, so no defensive checks are needed here.
  const { fileName, className } = names(options.name);
  const projectRoot = joinPathFragments('services', fileName);

  // Records creates in the in-memory Tree. Nothing is on disk yet.
  // `name` fills __name__ in file paths; all values are available
  // inside the template contents.
  generateFiles(tree, joinPathFragments(__dirname, 'files'), projectRoot, {
    ...options,
    name: fileName,
    className,
  });

  // Formats the pending changes, still in memory.
  await formatFiles(tree);

  // Nx flushes the Tree to disk after this function returns successfully.
  // The returned callback runs after that write, when real files exist.
  return () => {
    installPackagesTask(tree);
  };
}

A minimal local generator implementation that stages template output on the virtual Tree and returns a callback that runs after Nx writes the changes to disk.

How do you design the generator schema and service templates?

Keep the schema small. Four inputs cover most backend services: name, directory, an optional port (or transport, if you support HTTP and a message consumer), and team, which becomes a project tag such as team:payments. Every extra option doubles the template paths you have to test, and teams that skip options get inconsistent services anyway. Make name and team required, give port a default, and add a pattern to name so that Billing Service is rejected before anything runs.

The schema only checks shape, so validate meaning at the top of the generator function as well. Check that the target directory is empty, that the team appears in your allowed list, and that the port is in range. If any check fails, throw an error that tells the user what was wrong and how to fix it. The Tree has not been written yet at that point, so the user gets a clear failure instead of a project that looks valid but has no owner.

To render templates, call generateFiles with the template folder, the target path and a substitution object. Spread names(options.name) into that object so templates can use className for classes, fileName for paths and propertyName for variables. That way nobody hand-converts billing-service to BillingService.

Two naming conventions apply to template files. A segment like __fileName__ in a file or folder name is replaced with the matching substitution value. A .template suffix, as in __fileName__.controller.ts.template, is stripped when the file is written. The suffix matters because it stops TypeScript, ESLint and your test runner from treating EJS-laden files as real source, which would fail the build of the plugin itself.

import { Tree, addProjectConfiguration, formatFiles, generateFiles, joinPathFragments, names } from '@nx/devkit';

interface ServiceSchema { name: string; directory: string; port?: number; team: string; }

const ALLOWED_TEAMS = ['payments', 'identity', 'platform'];

export default async function serviceGenerator(tree: Tree, options: ServiceSchema) {
  const projectRoot = joinPathFragments(options.directory, options.name);
  const port = options.port ?? 3000;

  // Check meaning before writing anything, so a bad input never leaves a half-built project.
  if (!ALLOWED_TEAMS.includes(options.team)) {
    throw new Error(`Unknown team "${options.team}". Use one of: ${ALLOWED_TEAMS.join(', ')}.`);
  }
  if (tree.exists(projectRoot) && tree.children(projectRoot).length > 0) {
    throw new Error(`${projectRoot} is not empty. Pick another --directory or --name.`);
  }
  if (!Number.isInteger(port) || port < 1024 || port > 65535) {
    throw new Error(`Port ${port} is out of range. Use an integer between 1024 and 65535.`);
  }

  // The team tag records ownership in the project graph.
  addProjectConfiguration(tree, options.name, {
    root: projectRoot,
    sourceRoot: joinPathFragments(projectRoot, 'src'),
    projectType: 'application',
    tags: [`team:${options.team}`],
  });

  // files/src/__fileName__.controller.ts.template is written as src/billing-service.controller.ts.
  // Inside it, <%= className %>Controller renders as BillingServiceController.
  generateFiles(tree, joinPathFragments(__dirname, 'files'), projectRoot, {
    ...names(options.name), // className, fileName, propertyName, constantName
    port,
    team: options.team,
  });

  await formatFiles(tree);
}

This generator validates its inputs before touching the Tree, tags the project with its owning team, and renders .template files whose __fileName__ segments and EJS variables are filled from names().

How do you compose official plugin generators and update workspace files safely?

Start the generator by awaiting the application generator exported by your framework's official plugin, such as @nx/node or @nx/nest, with the same Tree. It already handles the build target, test runner, lint config and the base tsconfig, and those defaults improve when you upgrade Nx. Your generator should add only what makes the service yours. Call generateFiles to write the health endpoint, the structured logging bootstrap, the config loader and the Dockerfile. Then use readProjectConfiguration and updateProjectConfiguration to add tags such as type:service and scope:<team>, plus any CI-relevant targets like docker-build. If you find yourself rewriting files the official generator produced, the custom layer has grown too thick.

Shared files need more care. Templates suit new files, but they are fragile for editing existing ones. Appending text to tsconfig.base.json or CODEOWNERS breaks on trailing commas or reordered entries, and a second run duplicates lines. Use read-modify-write instead. updateJson parses the path mappings, lets you add a key, and serializes the result. For CODEOWNERS, read the file with tree.read, check whether the path is already present, and then tree.write it. Structured or AST-based edits take longer to write, but you can rerun them safely.

Finish with await formatFiles(tree). Then return a callback such as () => installPackagesTask(tree), because package installation has to happen after the virtual tree is written to disk.

Wrapping the official generators has a cost. You inherit their option names and output layout, so an Nx upgrade can break your generator even when your own code has not changed.

import {
  Tree, formatFiles, generateFiles, installPackagesTask, joinPathFragments,
  readProjectConfiguration, updateProjectConfiguration, updateJson,
} from '@nx/devkit';
import { applicationGenerator } from '@nx/node';

interface Schema { name: string; team: string; }

export default async function serviceGenerator(tree: Tree, opts: Schema) {
  const root = `apps/${opts.name}`;
  // The official plugin owns build, test, lint and base tsconfig.
  await applicationGenerator(tree, { name: opts.name, directory: root });

  // Thin org layer: health endpoint, logging bootstrap, config loader, Dockerfile.
  generateFiles(tree, joinPathFragments(__dirname, 'files'), root, { ...opts, tmpl: '' });

  // Tags and CI-relevant targets go through the project config API.
  const project = readProjectConfiguration(tree, opts.name);
  project.tags = [...(project.tags ?? []), 'type:service', `scope:${opts.team}`];
  project.targets = {
    ...project.targets,
    'docker-build': { executor: 'nx:run-commands', options: { command: `docker build -f ${root}/Dockerfile -t ${opts.name} .` } },
  };
  updateProjectConfiguration(tree, opts.name, project);

  // Parse, modify, serialize: survives formatting changes and is safe to rerun.
  updateJson(tree, 'tsconfig.base.json', (json) => {
    json.compilerOptions.paths ??= {};
    json.compilerOptions.paths[`@acme/${opts.name}/config`] = [`${root}/src/config/index.ts`];
    return json;
  });

  // Read-check-write so a second run does not duplicate the owner line.
  const owners = tree.read('.github/CODEOWNERS', 'utf-8') ?? '';
  if (!owners.split('\n').some((l) => l.startsWith(`/${root}/ `))) {
    tree.write('.github/CODEOWNERS', `${owners ? owners.trimEnd() + '\n' : ''}/${root}/ @acme/${opts.team}\n`);
  }

  await formatFiles(tree);
  // Runs only after the virtual tree has been written to disk.
  return () => installPackagesTask(tree);
}

A local Nx generator that wraps the official @nx/node application generator, adds a thin layer of organization conventions, edits tsconfig.base.json and CODEOWNERS with idempotent read-modify-write helpers, and defers package installation to a post-write callback.

How do you test an Nx generator so broken templates never reach teams?

Unit tests run the generator against a virtual tree created with createTreeWithEmptyWorkspace, so they are fast and need no disk or network. Assert on three things. First, that the expected files exist and contain the rendered values, such as the service name in the health endpoint and the correct port in the config. Second, that readProjectConfiguration returns the targets you expect. Third, that the project carries the right tags. A missing tag is easy to overlook in review, and later lint rules depend on it.

Also pin the output of the official generators you wrap. Assert on the files and target names they produce, not only on your own additions. When an Nx upgrade renames an option or moves a file, these tests fail in the upgrade pull request. Without them, the change would show up weeks later in a service scaffolded by someone else.

Passing tests on a virtual tree do not prove the service builds. The tree never runs the compiler, resolves imports, or loads your lint config. A template can be perfectly valid text and still contain a type error or import a package that is missing from the root package.json. To catch this, add a CI smoke test that runs the generator in a clean checkout, then runs build, lint, and test on the new project.

A generator only controls day one. After that, services drift as people edit them. Tags combined with the module boundary lint rule are what keep services consistent over time: for example, a type:service project cannot import from another service, and that rule is enforced on every commit, not just at scaffolding time.

import { Tree, readProjectConfiguration } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { serviceGenerator } from './generator';

describe('service generator', () => {
  let tree: Tree;

  beforeEach(async () => {
    // In-memory workspace: no disk, no network, runs in milliseconds
    tree = createTreeWithEmptyWorkspace();
    await serviceGenerator(tree, { name: 'orders', port: 3101 });
  });

  it('renders templates with the service values', () => {
    const health = tree.read('apps/orders/src/health.ts', 'utf-8');
    expect(health).toContain("service: 'orders'");
    const config = tree.read('apps/orders/src/config.ts', 'utf-8');
    expect(config).toContain('3101');
  });

  it('pins the output of the wrapped official generator', () => {
    // These paths come from the generator we wrap, not our templates.
    // If an Nx upgrade moves them, this fails in the upgrade PR.
    expect(tree.exists('apps/orders/src/main.ts')).toBe(true);
    expect(tree.exists('apps/orders/tsconfig.app.json')).toBe(true);
  });

  it('registers the expected targets and tags', () => {
    const project = readProjectConfiguration(tree, 'orders');
    // Assumes the generator writes targets explicitly to project.json
    expect(Object.keys(project.targets ?? {}).sort()).toEqual(['build', 'lint', 'serve', 'test']);
    // Module boundary lint rules depend on these tags existing
    expect(project.tags).toEqual(expect.arrayContaining(['type:service', 'scope:orders']));
  });
});

A Jest unit test that runs a local service generator against a virtual tree and checks the rendered files, the pinned output of the wrapped official generator, the project targets, and the tags.

How do you run the generator day to day and evolve the baseline?

Generators only act at creation time. Lint rules keep checking every project after that.

Run it with a dry run first

Put the dry run in the README as the first step: nx g @acme/tooling:service orders --dry-run. It lists every file the generator would create or modify, including edits to shared config. This lets a reviewer see those edits before anything touches disk. Remove --dry-run to run it for real. The README should open with this single command, followed by a short list of the schema options. A new engineer should not need to read the generator source to create a service.

Generators are one-shot

A generator writes files once and keeps no link to the project it created. If you change the template next quarter, the services created earlier stay as they were. To move them to the new baseline, you have three options:

Approach Best for Maintenance cost
Migration-style generator (iterates projects tagged type:service and edits them via the Tree API) Mechanical changes across many services, such as renaming a config key or adding a health-check target High. It needs tests against several project shapes, and services that drifted from the template will break naive edits.
Lint rule Rules that can be checked statically, such as banned imports or required tags Low once written. It covers old and new code, but it only reports problems and does not fix them.
Manual follow-up PRs Two to five services, or changes that need judgement Grows with the number of services. Nothing is left to maintain afterwards.

Write a migration generator only when the change is mechanical and there are enough services that editing them by hand would be slower and more error-prone than testing the automation.

When should you avoid a local generator, and what are the alternatives?

A local generator is code, and code without an owner decays. Each Nx upgrade can change generator APIs or the output of the official plugin generators you compose, and each framework upgrade can make your templates outdated. If no platform team owns the generator, the next service created from it will start from last year's baseline.

Over-parameterization is the usual way generators fail. Every boolean flag doubles the template combinations you have to test, and most of those combinations get used rarely or never. Two narrow generators, such as http-service and queue-worker, are easier to read, test, and change than one generator with a dozen options.

Keep the scaffold thin. Generated code becomes the team's own code the moment it is written, so any logging, auth, or error-handling behavior baked into it cannot be updated centrally. Put that behavior in shared libraries that services import, and let the generator only wire those libraries in.

A local generator also stops at the repository boundary. To share it with other repositories, you need a published plugin package, along with versioning, a changelog, and release work.

Option Development cost Fits when
Copy an existing service None up front, high drift One-off services
Template repository Low Polyrepo setups, rare new services
Official plugin generators only None Defaults are close enough
Local generator Moderate, ongoing Many services in one workspace
Published Nx plugin High: releases, versioning Many repositories
Shared libraries Moderate Behavior that must stay updatable

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 local generator, 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.