What Nx does in a Java monorepo (and what it does not)
Nx does not replace Gradle or Maven. Gradle still compiles, tests and packages the Gradle modules, and Maven still does the same for its modules. Nx sits above both. It reads which projects exist, builds a dependency graph across all of them, runs each tool's tasks in the right order, and restores cached results when a task's inputs have not changed. Nx does not resolve classpaths, apply plugins or decide what goes into a JAR.
The example repository looks like this:
┌──────────────────────────────┐
│ Nx: project graph, task │
│ ordering, affected, cache │
└──────────────┬───────────────┘
┌────────────────────┴────────────────────┐
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Gradle │ │ Maven │
│ │ │ │
│ shared-domain ─┼──► publish ──► Maven ─┼──► billing-svc │
│ │ │ │ repo │ (Spring Boot) │
│ ▼ ▼ │ └─────────────────┘
│ orders users │
│ -svc -svc │
└─────────────────┘
Inside Gradle, orders-svc and users-svc depend on shared-domain as project dependencies, so the edges are explicit. billing-svc gets the same library as a versioned artifact from a Maven repository. Neither build tool records that edge as a project-to-project link. If Nx does not know about it, a change to shared-domain will leave billing-svc out of the affected set.
This is where Nx earns its place. Its generators are a minor convenience. The real benefit is a graph that matches how the code actually depends on itself. The teams that gain the most are platform and backend groups running mixed Gradle/Maven or mixed-language repositories who want one affected-based CI pipeline instead of separate logic for each build tool.
┌────────────────────────────────────────────────────────────────────┐
│ Nx: reads projects, builds graph, orders tasks, affected, cache │
│ Does not compile, resolve classpaths or package JARs │
└───────────┬──────────────────────────────────────────┬─────────────┘
│ invokes Gradle tasks │ invokes Maven goals
▼ ▼
┌──────────────────────────────┐ ┌──────────────────────────┐
│ Gradle build │ │ Maven build │
│ │ │ │
│ ┌──────────────────┐ │ │ ┌────────────────────┐ │
│ │ shared-domain │ │ │ │ billing-svc │ │
│ │ (library) │ │ │ │ (Spring Boot) │ │
│ └──┬─────────┬─────┘ │ │ └─────────▲──────────┘ │
│ │project │project │ │ │ versioned │
│ ▼ ▼ │ │ │ dependency │
│ ┌─────────┐ ┌─────────┐ │ └────────────┼─────────────┘
│ │orders- │ │users- │ │ │
│ │svc │ │svc │ │ │
│ └─────────┘ └─────────┘ │ │
└──────┬───────────────────────┘ │
│ publish JAR no project link in │
▼ either tool; Nx must │
┌──────────────────────────────┐ be told about it │
│ Maven repository ├─────────────────────────┘
└──────────────────────────────┘
Nx orchestrates Gradle and Maven from above. The Gradle services depend on shared-domain through explicit project dependencies, while billing-svc pulls it as a versioned artifact from a Maven repository, an edge that Nx has to be told about.
Adding Nx to an existing Gradle Spring Boot build
Start from the repository root, where settings.gradle (or settings.gradle.kts) lists the Spring Boot services and shared libraries. Running nx add @nx/gradle installs the plugin and registers it in nx.json. Your Gradle files stay as they are, and nothing is generated per module. Nx reads the build's own project model instead. It asks Gradle for its subprojects, the tasks each one exposes, and the project dependencies declared between them. It then creates one Nx project per Gradle subproject, with targets mapped to that subproject's Gradle tasks.
Inference matters because Gradle stays the source of truth. If someone adds implementation(project(":shared-security")) to a service, the Nx graph picks up that edge on the next run, and you have no parallel config that can drift.
Check the result before you trust it:
nx show projectsshould list every subproject you expect. A missing module usually means it is not included in the settings file.nx show project <name>prints the inferred targets, inputs, and outputs for one project. Confirm that the targets match the Gradle tasks you actually run in CI.nx graphshows the dependency graph, so you can spot an unexpected edge or a library that nothing appears to use.
Inferred target names and plugin options have changed between Nx releases. Look them up in the Nx docs for your installed version rather than copying names from this article.
Nx invokes Gradle for both graph computation and task execution, so Gradle's startup cost is paid often. Keep the Gradle daemon enabled. If your build already works with Gradle's configuration cache, turn it on as well, because it cuts the repeated configuration time on every invocation.
# Run from the repo root, next to settings.gradle(.kts) and gradlew.
# 1. Install the plugin and register it in nx.json.
# Gradle build files are left untouched.
npx nx add @nx/gradle
# 2. Every Gradle subproject in the settings file should appear here.
npx nx show projects
# 3. Inspect inferred targets, inputs and outputs for one service.
# Use a name printed by the previous command. Compare the targets
# against the Gradle tasks your CI actually runs.
npx nx show project orders-service
# 4. Open the dependency graph to check edges such as
# orders-service -> shared-security.
npx nx graph
# 5. Before enabling the configuration cache permanently, confirm the
# build supports it. Run it twice: the second run should reuse the cache.
./gradlew build --configuration-cache
./gradlew build --configuration-cache
# 6. If both runs succeed, persist the settings in gradle.properties.
# The daemon is on by default; setting it explicitly guards against
# a machine-level override that disables it.
cat >> gradle.properties <<'EOF'
org.gradle.daemon=true
org.gradle.configuration-cache=true
EOF
Adding @nx/gradle to an existing Spring Boot Gradle build, then checking the inferred projects and graph. The example also verifies configuration-cache support before keeping the daemon and the configuration cache enabled in gradle.properties.
Bringing Maven modules and cross-build-tool dependencies into the graph
Maven support in the Nx ecosystem has lagged behind Gradle. Before you build anything by hand, check the Nx documentation for your version and see whether it lists an official, stable Maven plugin. If it doesn't, use the fallback: one project.json per Maven module, with nx:run-commands targets that call the Maven wrapper.
Each build and test target runs ./mvnw -pl <module> -am with the goal it needs. The -am flag also builds the module's in-reactor dependencies, so the command works from a clean checkout. Add a serve target that runs spring-boot:run. Name it the same as the serve target that wraps bootRun on the Gradle services, so developers start any service the same way regardless of its build tool. Set cache: false on both serve targets. They are long-running processes with no outputs, and replaying one from cache makes no sense.
The Gradle-to-Maven boundary
Maven cannot read a Gradle project. The shared library has to arrive as a published artifact. Apply maven-publish to the library and run publishToMavenLocal for local work. In CI, publish to your internal repository so build agents don't depend on each other's ~/.m2.
Nx can't infer this edge, so declare it yourself:
- Add the library to the Maven service's
implicitDependencies. This keeps the project graph andnx affectedcorrect. - Give the service's
buildtarget adependsOnentry for the library's publish target. This makes the artifact exist before Maven resolves it.
Drift risk
When someone adds a module to the parent POM but doesn't add a matching project.json, Nx never sees that module. Its changes won't show up in affected, and its tests silently stop running in CI.
{
"name": "orders-service",
"root": "services/orders",
"projectType": "application",
"implicitDependencies": ["shared-lib"],
"targets": {
"build": {
"executor": "nx:run-commands",
"dependsOn": [{ "projects": ["shared-lib"], "target": "publish" }],
"options": {
"command": "./mvnw -pl services/orders -am install -DskipTests"
},
"outputs": ["{projectRoot}/target"]
},
"test": {
"executor": "nx:run-commands",
"dependsOn": [{ "projects": ["shared-lib"], "target": "publish" }],
"options": {
"command": "./mvnw -pl services/orders -am verify"
}
},
"serve": {
"executor": "nx:run-commands",
"dependsOn": ["build"],
"cache": false,
"options": {
"command": "./mvnw -pl services/orders spring-boot:run"
}
}
}
}
A project.json for a Maven service that wraps the Maven wrapper with -pl/-am. It declares the Gradle shared-lib as an implicit dependency, publishes that library before building, and adds a non-cacheable serve target that runs spring-boot:run.
Running nx affected for Java projects in CI
Locally, nx affected compares your working tree against the default base. In CI you set both ends explicitly. Run nx affected -t build test --base=<base> --head=<head>, where the target names are whatever the Gradle plugin inferred and whatever you declared for Maven projects. On a pull request, the base is usually the merge-base with origin/main. On pushes to main, it is often the last commit that built green. NX_BASE and NX_HEAD are the usual way to pass these in.
Nx computes affected projects from a git diff, so the base commit must exist in the clone. A shallow clone with depth 1 drops it, and the diff fails. Depending on how your scripts handle that failure, you get either a full rebuild or a silently empty run. Fetch full history, or at least enough to include the merge-base.
History rewrites cause the same problem in subtler ways:
- Force-push: A force-push can make a stored "last green" SHA unreachable.
- Squash merge: Squash-merging a parent branch strands stacked branches on an old merge-base, which inflates the affected set.
- Too-recent base: Setting the base to
HEAD~1on a multi-commit push misses earlier commits entirely.
Before running targets, log nx show projects --affected with the same base and head. This makes the selection reviewable.
Suppose you change a class in the shared domain library. The affected set is:
domain- the Gradle services that declare it as a dependency
- the Maven
inventory-service, which is included only because of the explicit edge from the previous section
Nx then runs build and test for each of these projects in dependency order.
Edges can go missing, as the drift risk above showed. Schedule a full nx run-many -t build test on main, nightly or weekly, to catch what affected-only runs skip.
#!/usr/bin/env bash
# CI step for a pull request: compute base/head explicitly and fail loudly
# instead of silently rebuilding everything or nothing.
set -euo pipefail
# Base detection needs the merge-base commit to exist locally.
# A depth-1 clone drops it, so fetch full history if the clone is shallow.
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --unshallow origin
fi
git fetch origin main
# PR builds: diff against the merge-base with main, not HEAD~1,
# so every commit on the branch is included.
NX_BASE="$(git merge-base origin/main HEAD)" || {
echo "No merge-base with origin/main (rewritten history?). Refusing to guess." >&2
exit 1
}
NX_HEAD="$(git rev-parse HEAD)"
export NX_BASE NX_HEAD
echo "base=${NX_BASE} head=${NX_HEAD}"
# Log the selection first so reviewers can check it. For a change in
# the shared 'domain' library, expect domain, the Gradle services that
# depend on it, and inventory-service (Maven, via the explicit edge).
npx nx show projects --affected --base="$NX_BASE" --head="$NX_HEAD"
# Run the inferred Gradle targets and the declared Maven targets.
# Nx orders tasks by the project graph, so domain builds first.
npx nx affected -t build test --base="$NX_BASE" --head="$NX_HEAD"
# Safety net, run from a scheduled job on main (nightly or weekly),
# not here: catches anything a missing graph edge would skip.
# npx nx run-many -t build test
A pull-request CI script that unshallows the clone, sets the base to the merge-base with main, logs the affected projects, runs build and test on them, and notes the scheduled full build that acts as a safety net.
Caching JVM tasks without stale results
A wrong cache hit is worse than a slow build. A slow build costs minutes. A restored JAR built against a stale parent POM can pass CI and fail in production, and nobody will suspect the cache. Keep inputs broad and narrow them only when you can show they are too wide.
In nx.json, define a namedInputs entry, for example jvmShared, that lists the files affecting every JVM project:
gradle/libs.versions.tomlgradle/wrapper/gradle-wrapper.properties- the convention plugin sources under
buildSrcor your included build - the parent
pom.xml
Add an env input for JAVA_HOME. Also add a runtime input whose command prints the JDK version to stdout, because the same path can point to a different JDK on another agent. Reference jvmShared from targetDefaults for build and test. Then a change to a convention plugin or parent POM invalidates every project that depends on it.
Declare outputs explicitly, such as {projectRoot}/build/libs, {projectRoot}/build/test-results and {projectRoot}/target. Otherwise a cache hit replays the logs but leaves no artifacts on disk.
| Task type | Nx cache | Reason |
|---|---|---|
| Compile, package | Yes | Deterministic from declared inputs |
| Unit tests | Yes | No external state |
| Testcontainers, shared DB, network | cache: false |
Result depends on external state |
| Profile-driven via env vars | Off, or add each var as env input |
Hidden inputs |
The two caching layers do not conflict. An Nx hit skips the Gradle invocation entirely. On an Nx miss, Gradle's up-to-date checks and build cache still run underneath. Keep both enabled: Nx skips whole tasks, and Gradle reuses work inside the tasks that do run.
Impact on cost and engineering time
Illustrative estimate (assumed numbers, not a benchmark)
Assume 20 Spring Boot services, each taking 4 minutes to compile and test on one CI agent. A typical pull request touches one shared library used by 2 services, so 3 projects are affected. Nx needs an assumed 1 minute to install Node dependencies and compute the graph. The team merges 30 pull requests a day.
- Full build: services × minutes per task = 20 × 4 = 80 agent-minutes per PR
- Affected build: (affected projects × minutes per task) + Nx overhead = 3 × 4 + 1 = 13 agent-minutes per PR
- Daily: 80 × 30 = 2,400 versus 13 × 30 = 390 agent-minutes
Plug in your own counts. The savings shrink as the affected ratio grows. A change to a root BOM or parent POM still rebuilds everything.
Per-project targets have a cost on Maven. Each separate mvn invocation starts a JVM, loads plugins, and resolves dependencies again. One reactor build pays that cost once. If each invocation has an assumed 20 seconds of overhead, 15 modules run as separate targets add 5 minutes that a single reactor build avoids. When modules are tightly coupled or usually change together, group them into one target that runs the reactor with -pl and -am.
Count the other costs too:
- Node.js on every agent. Nx adds a Node.js runtime that each CI agent and developer machine must have.
- More configuration files. You maintain
nx.jsonandproject.jsonfiles, plus hand-written Maven targets and inputs. - Plugin upgrades. Inferred targets can change between releases. Pin plugin versions and read the migration notes before each upgrade.
When to use Nx, when to avoid it, and alternatives
Nx earns its place when no single build tool can see the whole repository. Typical cases are Gradle services next to a Maven legacy module, Java backends sharing a repo with a TypeScript frontend, or a platform team that wants one affected command to drive CI across all of them. In those repos the cross-tool graph is the point, because neither Gradle nor Maven can model the other's projects.
In an all-Gradle repo, Nx mostly duplicates what you already have. Gradle's task avoidance, build cache and configuration cache already skip unchanged work using inputs that Gradle tracks itself, so adding Nx means a second graph and a second cache to keep correct. Avoid Nx as well if no one owns graph accuracy. An unmaintained graph is worse than having no graph.
| Approach | Graph accuracy | Caching | Cross-tool support | Maintenance burden |
|---|---|---|---|---|
| Nx over Gradle and Maven | Good if plugins infer edges and manual edges are kept current | Task-level, local and remote; only as correct as declared inputs | Yes | Medium to high |
| Gradle alone | High; derived from the build itself | Mature, input-tracked | Gradle only | Low |
| Maven reactor alone | High within the reactor | Limited without extensions | Maven only | Low |
| Path-filtered CI scripts | Low; globs drift from real dependencies | None | Any, crudely | Grows with every module |
Nx introduces two main risks. The first is a missing dependency edge. Nx then considers a service unaffected and skips its tests and deploy without reporting anything. The second is an under-declared input, such as a shared application.yml or a BOM. A change to that file produces the same task hash, so Nx returns a stale cache hit and reports a green build that never ran the changed code.
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 gradle plugin spring boot, 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.