How Nx and NestJS divide the work in a backend monorepo
In an Nx NestJS workspace, each tool has its own job. NestJS owns the inside of each service: modules, controllers, providers, dependency injection, and the request lifecycle. Nx owns everything around those services. It builds a project graph from your imports, orders tasks into a task graph, caches task results, detects which projects a change affects, and enforces module boundaries between projects. NestJS has no view of the other services in the repo. Nx has no view of your controllers.
This split helps backend and platform engineers who run several related services, such as an orders-api and a billing-worker, that share request/response contracts. When a DTO changes, Nx can identify both consumers, rebuild them, and test them in one commit. In separate repositories, the same change means coordinating published package versions.
The setup is a poor fit in three cases:
- One service. The graph and caching have little to optimize.
- Heavily customized NestJS CLI configuration. You would have to migrate it to Nx executors or keep two build systems.
- Services that need different framework or Node versions. A monorepo works best with a single dependency set at the root.
Use the official @nx/nest plugin to scaffold instead of wiring tsconfig path aliases by hand. Its application and library generators create the project configuration, register the import path in the root tsconfig.base.json, and set up lint and test targets. Each generated project then appears in the graph from the start, and you avoid aliases that the compiler resolves but Nx never sees.
# 1. Create an empty Nx workspace with no framework preset.
npx create-nx-workspace@latest acme --preset=apps
cd acme
# 2. Install the official NestJS plugin. This also registers its generators.
npx nx add @nx/nest
# 3. Generate two NestJS services. Each one gets project config plus
# build, serve, lint and test targets.
npx nx g @nx/nest:application apps/orders-api
npx nx g @nx/nest:application apps/billing-worker
# 4. Generate a shared library for request/response DTOs.
# The generator registers the import path, so services import
# '@acme/shared-dto' instead of relative paths into another project.
npx nx g @nx/nest:library libs/shared/dto --importPath=@acme/shared-dto
# 5. Open the project graph. Once a service imports the library,
# an edge from that service to shared-dto appears in the graph.
npx nx graph
# 6. After editing a DTO, test only the projects the change affects.
# Here that is shared-dto plus every service that imports it.
npx nx affected -t test
These commands scaffold an Nx workspace with two NestJS services and a shared DTO library using the official @nx/nest generators, then use the project graph and affected detection to test only what a DTO change touches.
Workspace layout: one app per deployable service, libraries by type
Generating with the right import paths only helps if the folders follow a predictable structure. The convention here is that each directory under apps/ maps to exactly one deployable artifact, and everything reusable lives in libs/, grouped by scope and then by type.
apps/
orders-api ──────┐ billing-api ─────┐
▼ ▼
libs/orders/feature-checkout libs/billing/feature-invoicing
│ │
▼ ▼
libs/orders/data-access libs/billing/data-access
│ │
└──────────┬──────────────┘
▼
libs/shared/contracts libs/shared/util
Arrows point only downward. An orders library never imports a billing library. The two scopes communicate through shared/contracts, which holds the request, response and event shapes that both sides agree on.
| Type | Contains | May depend on |
|---|---|---|
| feature | NestJS modules, controllers, use-case services | data-access, util, contracts |
| data-access | Repositories, ORM entities, external clients | util, contracts |
| util | Pure helpers with no framework or I/O | util |
| contracts | DTOs, event payloads, enums | nothing outside contracts |
The scope dimension (orders, billing, shared) answers which team owns the code. The type dimension answers what the code is allowed to touch. Section 4 uses both dimensions as tags.
Granularity is a judgment call. Every library brings its own project config, lint and test targets, and one more node in the graph for engineers to keep track of. With too many libraries, that overhead adds up. With too few, a single orders library holding controllers, entities and DTOs together causes problems of a different kind. Any edit to it marks every dependent project as affected, and boundary rules have nothing to separate. A reasonable starting point is one feature library per bounded use case. Split a library further only when its parts change for different reasons.
apps/ +--------------------+ +----------------------+
| orders-api | | billing-api |
+---------+----------+ +----------+-----------+
| |
v v
feature +--------------------+ +----------------------+
| orders/ | | billing/ |
| feature-checkout | | feature-invoicing |
+---------+----------+ +----------+-----------+
| |
v v
data-access +--------------------+ +----------------------+
| orders/data-access | | billing/data-access |
+---------+----------+ +----------+-----------+
| |
+----------------------------+
| |
v v
shared +--------------------+ +----------------------+
| shared/contracts | | shared/util |
| DTOs, events, enums| | pure helpers, no I/O |
+--------------------+ +----------------------+
Arrows are allowed imports and point down only. Feature libs may also
import shared/* directly. orders/* and billing/* never import each
other; they agree on shapes only through shared/contracts.
One app per deployable service, with libraries grouped by scope (orders, billing, shared) and type (feature, data-access, contracts, util), and imports allowed only downward toward the shared layer.
Building shared DTO libraries that stay framework-light
Generate the contracts library with a plain TypeScript generator such as @nx/js:library, not the Nest one, so it starts without Nest dependencies. Both services then import it through the workspace alias, for example @acme/contracts. Nx maps this alias in tsconfig.base.json, or resolves it as a workspace package in setups that use TypeScript project references. Because of that import, Nx records an edge in the project graph, and a change to a DTO marks both services as affected.
The main design decision is where validation decorators live.
| Approach | Nest services | Non-Nest consumers (e.g. frontend) |
|---|---|---|
| Decorators in shared DTO classes | ValidationPipe works directly on shared classes |
Must install class-validator, class-transformer and reflect-metadata, and enable decorator compiler options |
| Plain interfaces/types in contracts, decorated classes in each service | Small duplication: service classes implement the shared interface |
Zero runtime cost; types erase at compile time |
The second approach costs a few extra lines per DTO. In return, the contract never forces Nest's runtime model on other consumers.
Contracts must not import NestJS modules, database entities, or service code. If an ORM entity leaks into a response type, every consumer depends on one service's table schema. A column rename then becomes a cross-service breaking change, and boundary tags cannot catch it because the leak looks like a legitimate contract import.
Treat contract changes as additive. Add optional fields, and never rename or remove a field in place. A single merge updates both services in the repo, but a rolling deploy runs old and new versions side by side. When a breaking change is unavoidable, publish a new versioned DTO such as CreateOrderV2 alongside the old one. Remove the old DTO only after no deployed version still uses it.
// libs/contracts/src/lib/orders.ts
// Plain types only: no @nestjs/*, ORM entities, or class-validator imports.
export interface CreateOrderRequest {
customerId: string;
sku: string;
quantity: number;
// Added later as optional so older callers still match the contract.
note?: string;
}
export interface OrderResponse {
id: string;
status: 'pending' | 'paid' | 'shipped';
// Copy the fields you need. Never reference the ORM entity type here.
createdAt: string; // ISO 8601 string, not the entity's Date column
}
// A breaking shape change gets a new version next to the old one.
// Remove V1 only after no running deployment uses it.
export interface CreateOrderRequestV2 {
customerId: string;
items: { sku: string; quantity: number }[];
}
// apps/orders-service/src/orders/create-order.dto.ts
// Validation decorators stay in the Nest service, so the frontend never needs them.
import { IsInt, IsOptional, IsString, Min } from 'class-validator';
import type { CreateOrderRequest } from '@acme/contracts';
export class CreateOrderDto implements CreateOrderRequest {
@IsString() customerId!: string;
@IsString() sku!: string;
@IsInt() @Min(1) quantity!: number;
@IsOptional() @IsString() note?: string;
}
A framework-free contracts library defines request and response shapes, including an additive optional field and a versioned V2 DTO, and a Nest service implements the shared interface with its own class-validator decorators.
Enforcing module boundaries with tags and the Nx lint rule
Shared DTOs make it easy to import across services. Without rules, the billing service will eventually import an internal helper from the orders domain, and the two services will no longer deploy independently. Nx enforces boundaries with project tags and a lint rule. The rule fails the build when an import crosses a boundary you have disallowed.
Tagging projects
Each project's project.json has a tags array. Use two dimensions:
- Scope (
scope:orders,scope:billing,scope:shared) says which domain owns the project. - Type (
type:app,type:feature,type:data-access,type:util) says which layer it belongs to.
The DTO library from the previous section would be scope:shared and type:util.
Configuring the rule
In your ESLint config, the @nx/enforce-module-boundaries rule takes a depConstraints list. Each entry pairs a sourceTag with onlyDependOnLibsWithTags. For this workspace:
scope:billingmay depend only onscope:billingandscope:shared.type:utilmay depend only ontype:util.
The second constraint keeps utility code at the bottom of the graph. A DTO library that pulls in a feature module would also pull in that feature's providers and their dependencies.
What a violation looks like
Suppose a file in billing-feature imports a pricing service from orders-feature through its workspace path alias. Running nx lint billing-feature reports an error. The message states that a project tagged scope:billing can only depend on libs tagged scope:billing or scope:shared. The import is rejected before any code review.
The rule only holds if the setup is complete
Make sure every project has tags. The rule only checks the tags it is configured with, so an untagged project can satisfy no constraint, and gaps make it easy to misread what the rule allows. Also make sure lint runs as a required check in CI. If lint only runs on a developer's machine, nothing actually enforces the boundaries.
// eslint.config.js (workspace root, ESLint flat config)
// Each project.json declares its tags, e.g. libs/shared/dtos/project.json:
// "tags": ["scope:shared", "type:util"]
// and libs/billing/feature/project.json:
// "tags": ["scope:billing", "type:feature"]
const nx = require('@nx/eslint-plugin');
module.exports = [
...nx.configs['flat/base'],
...nx.configs['flat/typescript'],
{
files: ['**/*.ts'],
rules: {
'@nx/enforce-module-boundaries': [
'error',
{
allow: [],
depConstraints: [
// Domain isolation: billing never reaches into orders internals.
{ sourceTag: 'scope:billing', onlyDependOnLibsWithTags: ['scope:billing', 'scope:shared'] },
{ sourceTag: 'scope:orders', onlyDependOnLibsWithTags: ['scope:orders', 'scope:shared'] },
// Shared code must not depend on any domain.
{ sourceTag: 'scope:shared', onlyDependOnLibsWithTags: ['scope:shared'] },
// Layering: util libraries (including DTOs) stay at the bottom of the graph.
{ sourceTag: 'type:util', onlyDependOnLibsWithTags: ['type:util'] },
],
},
],
},
},
];
// Violation in libs/billing/feature/src/invoice.service.ts:
// import { PricingService } from '@acme/orders-feature';
// `nx lint billing-feature` then fails, reporting that a project tagged
// "scope:billing" can only depend on libs tagged "scope:billing" or "scope:shared".
An ESLint flat config that uses the @nx/enforce-module-boundaries rule with scope and type tag constraints, so billing cannot import orders libraries and util libraries cannot depend on feature libraries, followed by a sample violating import.
Project graph, affected commands, and cacheable task pipelines
Nx builds its project graph by analyzing source files: a TypeScript import of @acme/orders-dto from a service creates a dependency edge to that library, with no manual declaration. nx affected -t build test lint compares the current branch against a base, usually main, maps the changed files to projects, and walks the graph to find every dependent project. A one-line change in a widely shared DTO library therefore marks every service that imports it as affected. This is correct behavior, and it is a good argument for keeping DTO libraries small and split by domain rather than collecting everything in one shared-dtos package.
In nx.json, set targetDefaults so build has dependsOn: ["^build"]. The caret tells Nx to build a project's dependencies before the project itself. Declare outputs (for example {projectRoot}/dist or {workspaceRoot}/dist/{projectRoot}) so Nx knows which files to store and restore. Define a production named input that excludes spec files, so editing a test does not invalidate a build.
A task can be cached when it is deterministic. Nx hashes the declared inputs, the inputs of dependencies, and the command, and enables caching per target with cache: true. Problems come from anything outside those inputs:
- A build that embeds a timestamp produces a different output from the same hash.
- A build that reads
process.env.API_URLwithout declaring{ "env": "API_URL" }as an input will replay an artifact built against a different environment.
A slow build costs minutes. A stale cache hit produces a wrong artifact with no error, and you may only find out in production. See the Nx introduction for an overview of caching and the task graph.
Testing and packaging each NestJS service for deployment
Test at three layers, and give each a different job. Unit tests live with each library and run in isolation. A DTO library's tests check validation rules and transformations, not HTTP. Each service then needs tests that import the shared contracts directly, such as controller or provider specs that build requests from the real DTO classes, rather than copying payload shapes into fixtures. The per-app e2e suite boots the full Nest application and sends real requests through it.
This layering is what makes a contract change visible. If someone renames a field in a shared DTO, the project graph marks every consuming service as affected. Their service and e2e tests run and fail in the service that broke. Hand-written JSON fixtures would keep passing and hide the problem.
For packaging, build each app into its own output directory, one artifact per deployable. Don't copy the workspace root package.json into the image, because it lists every dependency in the monorepo. With Nx's Node build tooling, you can generate a trimmed package.json containing only the packages that app's bundle actually imports. You then install production dependencies from it inside the container. If you manage this file by hand instead, check it in CI against the build output so it doesn't drift.
Keep environment configuration in the app. Read and validate variables at startup, for example in the app's own ConfigModule setup. Shared libraries should receive configuration through injection and never read process.env themselves. Otherwise one image can't move between environments, and library tests start depending on whatever the shell happens to export.
Alternatives, development cost, and where engineering time is saved
Nx is one of several ways to run multiple NestJS services from a single codebase. The alternatives below make different trade-offs.
| Approach | What you get | What it costs |
|---|---|---|
| NestJS CLI monorepo mode | Apps and libraries in one repo with no extra tool | No project graph, caching, affected detection or boundary rules |
| Package-manager workspaces plus a separate task runner | Per-package dependency versions are possible | You assemble the graph, caching and boundary checks yourself |
| Polyrepos with published contract packages | Independent release and upgrade cadence per service | Every DTO change needs a publish, a version bump and an update in each consumer |
Nx has its own costs. You adopt its plugin conventions, and each upgrade runs through nx migrate, which you should review rather than trust blindly. A single-version dependency policy means a major NestJS or Node upgrade moves every service at once. That forces coordination, though it also stops drift between services. When a shared DTO changes its wire format, the producing and consuming services may need to deploy together.
The savings come mostly from CI. Nx runs only the projects a change affects, replays cached tasks, scaffolds new services with generators, and turns import-direction review into a lint failure.
Illustrative estimate (assumed numbers, not a benchmark):
Saved compute minutes per week = (N − A) × T × P
The assumed inputs are:
- N = 12 services
- A = 3 affected services per PR
- T = 10 minutes of CI per service
- P = 40 PRs per week
That gives (12 − 3) × 10 × 40 = 3,600 minutes saved per week. This figure excludes cache hits.
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 nestjs, 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.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
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.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- 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.