What Nx with @nx/gradle is
Nx is an open-source build system and monorepo tool. It models a repository as a graph of projects, runs tasks across that graph, and caches task results by hashing their inputs. @nx/gradle is the Nx plugin that connects Nx to Gradle builds. It reads an existing Gradle multi-project build and exposes each subproject as an Nx project and each relevant Gradle task as an Nx target.
For a Spring Boot monorepo, you keep writing build.gradle.kts files, applying the Spring Boot plugin and running ./gradlew. Nx adds three capabilities that plain Gradle does not provide in the same form:
- a workspace-wide project graph you can query and visualise
- "affected" detection that limits builds and tests to projects reachable from a Git diff
- a computation cache that can skip a whole target, including starting Gradle, when nothing relevant has changed
Nx does not replace Gradle, compile Java, or resolve Maven dependencies. Gradle still does that work.
Who should use it
This setup fits teams that:
- keep several Spring Boot services and shared libraries in one Gradle multi-project repository
- spend most CI time rebuilding and retesting projects that a given change cannot affect
- already use, or can adopt, a Node.js toolchain alongside the JDK in CI and on developer machines
- have someone, usually a platform or build engineer, who can own both the Gradle build and the Nx configuration and keep them in step
It is a weaker fit for single-service repositories, for builds that are not yet deterministic, and for teams that want one build tool and one configuration surface. The last section covers these trade-offs and the alternatives in more detail.
How it works
The flow has four stages.
- Graph construction. The
@nx/gradleplugin runs the Gradle wrapper to evaluate the build. It turns every subproject insettings.gradle(.kts)into an Nx project, every relevant task into a target, and everyproject(":...")dependency into a graph edge. - Task selection. When you run a command such as
nx affected -t build test, Nx compares two Git refs and maps the changed files to projects. It then walks the graph to find dependents and orders the tasks so dependencies run first. - Hashing. For each task, Nx computes a hash from the target's configured
inputs. These can include project files, shared workspace files, and the hashes of dependency projects. If a stored result matches the hash, Nx restores the outputs and the terminal log instead of running the task. - Delegation. On a cache miss, Nx invokes Gradle for that target. Gradle then applies its own up-to-date checks and, if enabled, its own build cache at the level of individual tasks.
Correctness depends on stage 3. If a file that affects the output is missing from the Nx inputs, Nx can replay a stale result. The sections below explain how the graph is inferred, where it has blind spots, and how to configure inputs so both caches stay correct.
How @nx/gradle infers projects and targets from a Gradle multi-project build
In this setup Nx does not build anything itself. It sits above Gradle as a project-graph and task-orchestration layer. It decides which projects a change touches, in what order to run their tasks, and whether a result can be restored from cache. Gradle still owns compilation, dependency resolution, the Spring Boot plugin and any custom build logic. That split is why adoption is low-risk: your build.gradle.kts files remain the source of truth, and removing Nx leaves a working Gradle build.
To add Nx to an existing Java monorepo, run nx init or nx add @nx/gradle from the repository root, next to the Gradle wrapper. The plugin is registered in nx.json. It invokes ./gradlew to read settings.gradle(.kts), so each included subproject becomes an Nx project. It also reads each project's task graph and turns Gradle tasks into Nx targets. Expect targets named after the underlying tasks, such as build, test and bootJar, with project-to-project edges taken from project(...) dependencies. The plugin derives the graph from Gradle's own model, not from scanning files, so nx run orders-service:test delegates to Gradle rather than reimplementing it.
Inference has limits. Custom tasks registered by convention plugins in buildSrc or an included build may appear without useful inputs and outputs. Non-standard source layouts and generated-code tasks often need targets declared manually in project.json. Inference also requires Gradle to configure the whole build. On very large builds that configuration pass adds noticeable overhead whenever Nx recomputes the graph.
./gradlew projects # sanity check: Gradle can configure the whole build
npx nx add @nx/gradle
npx nx show projects
npx nx show project orders-service
npx nx graph
npx nx run orders-service:test
npx nx run-many -t build
Adding @nx/gradle to an existing Gradle wrapper build and inspecting the Nx projects, targets and dependency graph it infers from Gradle's own model.
Reading the project graph to find coupling between services and shared libraries
Once inference has run, every project(":...") reference in a module's dependencies block becomes an edge in the Nx project graph. A Spring Boot app that declares implementation(project(":libs:billing-core")) gets an edge to billing-core. That edge is what later lets Nx decide which projects a change affects. Run nx graph to open the interactive view, then focus on a single service to see everything it reaches transitively.
The graph shows the structure you actually have, not the one you meant to build. Look for two patterns in particular:
- Service-to-service edges. If
orders-servicedepends on a module insidepayments-service, the two can no longer be released independently. Any change to payments now marks orders as affected. Usually the shared code belongs in a library that both services depend on. - Heavy shared libraries. A "common" module that pulls in a web or JPA starter forces every consumer to inherit an embedded server or a datasource auto-configuration, whether it needs one or not. A library at the bottom of the graph with dozens of incoming edges is the first place to check.
The graph has blind spots. It only models declared project dependencies. Anything a build reaches by file path produces no edge, including:
- a shared
application.ymlor logging config referenced throughrootProject.file(...) - test fixtures or resources copied from a sibling directory
- a source set pointed at another module's folder
Edits to those files can change a project's output without Nx marking that project as affected. Note each one as you find it, because these are the inputs you will need to declare explicitly later.
// apps/orders-service/build.gradle.kts
dependencies {
// Declared project dependencies: each becomes an edge in the Nx graph.
implementation(project(":libs:billing-core")) // expected: service -> shared lib
implementation(project(":apps:payments-service")) // coupling smell: service -> service
implementation("org.springframework.boot:spring-boot-starter-web")
testImplementation("org.springframework.boot:spring-boot-starter-test")
}
// Blind spots: everything below is reached by file path, so Nx draws NO edge.
tasks.processResources {
// Shared runtime config pulled from the repo root.
from(rootProject.file("config/shared/application.yml"))
from(rootProject.file("config/shared/logback-spring.xml"))
}
sourceSets {
test {
// Test fixtures borrowed from a sibling module's folder.
resources.srcDir("../payments-service/src/test/resources/fixtures")
}
}
// Editing config/shared/application.yml or the payments fixtures changes this
// module's output, but Nx will not mark orders-service as affected until those
// paths are declared as inputs.
A Gradle Kotlin DSL build script where the project(...) references become Nx graph edges, including one service-to-service edge worth flagging, while the path-based config and fixture references produce no edges at all.
Running affected builds and tests against a base branch
Nx works out what is affected in two steps. First it lists the files that differ between two Git refs and maps each file to the project whose root contains it. Then it walks the project graph from those projects to every project that depends on them, directly or transitively. nx affected -t build test runs the targets only on that set.
Take a shared library, libs/common-security, that orders-service and billing-service both declare as a Gradle project dependency. Edit a filter class in the library, and Nx marks the library and both services as affected. Both services get rebuilt and tested because either could break against the new code. Edit a controller inside orders-service instead, and only that service runs. Nothing depends on a Boot application, so the walk stops there.
Set the comparison with --base and --head, or with the NX_BASE and NX_HEAD environment variables. On a pull request, use the target branch as the base, such as origin/main, and HEAD as the head. Nx diffs against the merge base of the two refs, so commits that landed on main after you branched do not count as your changes. In CI, the clone must include enough history for Git to find that merge base.
Now use the list from the previous section. For each file Gradle does not model, add it to the relevant target's inputs (for example, a {workspaceRoot}/... path in targetDefaults). That way, a change to the file alters the computation hash, and Nx will not replay a stale cached result. If the file belongs to another project, also add an implicitDependencies entry so the affected walk reaches the consumer.
#!/usr/bin/env bash
set -euo pipefail
git fetch --no-tags origin main:refs/remotes/origin/main
git merge-base origin/main HEAD > /dev/null
export NX_BASE=origin/main
export NX_HEAD=HEAD
npx nx show projects --affected
npx nx affected -t build test
A CI script that fetches enough Git history to find the merge base, sets NX_BASE and NX_HEAD, and lists the affected projects before building and testing them; the example outputs show a shared-library change reaching both services and a service-only change staying within that service.
Gradle build cache vs Nx computation cache: giving each a clear job
With inputs declared correctly, two caches can sit in front of the same build, and each needs a defined job.
| Gradle build cache | Nx computation cache | |
|---|---|---|
| Unit cached | Outputs of a single Gradle task | Outputs and terminal log of a whole Nx target |
| Cache key | Declared task inputs, classpath, task implementation | Nx inputs: files, runtime values, dependency hashes |
| Best at | Reusing compileJava or test output when only a sibling module changed |
Skipping build for an unchanged project entirely |
| Blind spots | Undeclared inputs inside custom tasks | Anything Gradle reads that is missing from Nx inputs |
Split the work this way. Gradle caches fine-grained compilation and test outputs, because it knows each task's real inputs. Nx skips whole targets for projects whose hash has not changed, so Gradle is never started for them.
The two layers can disagree. If Nx's inputs miss a file that Gradle tracks, such as a version catalog or a buildSrc convention plugin, Nx replays an old result while Gradle would have rebuilt. Both layers also store the same JARs and test reports, which doubles storage and upload time. If you need to trim that, narrow Nx outputs to the paths a later step actually consumes.
Prefer a miss over a wrong hit. A slow build costs minutes. A stale hit can ship a service compiled against an old DTO and still pass CI.
For remote caches, only trusted CI jobs on protected branches should write, for example by enabling Gradle's remote push only when a CI variable is set. Developer machines and fork pipelines get read-only access, so they cannot poison results that everyone else will reuse.
Making the Spring Boot build cache-correct before trusting either cache
Neither cache can be trusted while the build itself is non-deterministic. If identical sources produce different outputs, Gradle stores a new artifact on every run, and downstream tasks that consume those outputs miss as well.
Fix inputs, outputs, and non-determinism in Gradle
Every custom task needs declared inputs and outputs, or Gradle cannot fingerprint or cache it. Annotate properties with @InputFiles, @Input, and @OutputDirectory, and mark the task @CacheableTask. Use @PathSensitive(PathSensitivity.RELATIVE) so that absolute checkout paths do not leak into the cache key. If you skip this, a CI agent at a different path never hits.
Spring Boot adds three common sources of drift:
- Build info.
bootBuildInfowritesbuild.timeby default. Configure it to omit the time property, or the generated properties file changes on every build. - Git properties. Generated
git.propertiesfiles often include the commit time, branch, and dirty flags. Restrict them to the fields you actually serve from/actuator/info. - Archives. Set
preserveFileTimestamps = falseandreproducibleFileOrder = trueon all archive tasks, includingbootJar, so that identical contents produce byte-identical jars.
Tighten Nx named inputs
Define sharedGlobals to cover the root build scripts, settings.gradle(.kts), gradle/libs.versions.toml, gradle.properties, and shared config such as Checkstyle rules. Have default include all project files plus sharedGlobals.
Derive production from default by excluding **/*.md, docs/**, and src/test/**. Point build at production and ^production. Point test at default and ^production. With this setup, a README edit invalidates nothing, while a version-catalog bump correctly invalidates everything.
// nx.json (Nx parses this file as JSONC, so comments are allowed)
{
"namedInputs": {
// Workspace-level files that change the result of any Gradle build or test run
"sharedGlobals": [
"{workspaceRoot}/build.gradle.kts",
"{workspaceRoot}/settings.gradle.kts",
"{workspaceRoot}/gradle/libs.versions.toml",
"{workspaceRoot}/gradle.properties",
"{workspaceRoot}/config/checkstyle/**/*"
],
// All project files plus shared globals, minus docs, so a README edit misses nothing
"default": [
"{projectRoot}/**/*",
"!{projectRoot}/**/*.md",
"!{projectRoot}/docs/**/*",
"sharedGlobals"
],
// What ships in the jar: drop test sources so test-only edits do not rebuild dependents
"production": [
"default",
"!{projectRoot}/src/test/**/*"
]
},
"targetDefaults": {
// A jar depends on its own production files and its dependencies' production files
"build": {
"cache": true,
"inputs": ["production", "^production"]
},
// Tests also depend on their own test sources
"test": {
"cache": true,
"inputs": ["default", "^production"]
}
}
}
An nx.json that builds cache keys from the Gradle build scripts, version catalog, gradle.properties and shared Checkstyle config, and ignores Markdown and docs; build targets also ignore test sources, so a README edit causes no cache misses while a version-catalog bump invalidates every project.
Wiring CI and verifying cache hits and misses
A pipeline needs four things in order. First, check out with enough git history to contain the merge base between the branch and main. A shallow clone of depth one makes affected computation fail, or fall back to treating everything as changed. Second, install Node for the Nx CLI and a pinned JDK for the build. Third, invoke Gradle only through the wrapper (./gradlew) so every agent runs the version committed in gradle-wrapper.properties, not whatever is on the image. Fourth, run nx affected -t build test with --base set to the merge-base SHA and --head set to the current commit.
To share results across runs, persist two locations between jobs: Nx's cache directory and the Gradle build cache, which is local or a remote cache node. Key the restore on the lockfile and version catalog. Without persistence, every CI run starts cold and you only measure the cost of hashing.
Verify the setup locally before trusting it in CI:
- Run
nx run orders-service:buildtwice. Nx should report the second run as restored from cache. - Edit a Java source file under
src/mainand rerun. Expect a miss. - Edit the README and rerun. Expect a hit, which confirms your
productionexclusions work.
Then check Gradle separately. Run ./gradlew build --build-cache --console=plain after clean, and look at the outcome label next to each task. FROM-CACHE means outputs came from the build cache. UP-TO-DATE means outputs already on disk matched. A compileJava task that never reaches either state after a clean build points to a non-deterministic input that you still need to fix.
When to use it, when to avoid it, alternatives, and the cost in engineering time
Nx over Gradle pays off when a repository holds many Spring Boot services and shared libraries, and CI time goes mostly to rebuilding projects that a change cannot affect. Defer it in three cases:
- You have only a few services. There is little to skip, so affected detection saves little.
- The build is not yet cache-correct. Caching non-deterministic outputs makes failures harder to trace without making builds faster.
- The team cannot absorb a Node.js toolchain. Nx also adds a second configuration surface that must follow every Gradle change.
| Option | Change detection | Extra toolchain | Main ongoing cost |
|---|---|---|---|
Nx + @nx/gradle |
Project graph, affected vs. base branch | Node.js, Nx config | Two configuration surfaces |
| Plain Gradle (build cache, configuration cache, parallel execution) | Task-level up-to-date checks only | None | Every project still configures and checks its tasks |
| Gradle-native detection scripted in CI | Custom git-diff-to-project mapping | None | Script must mirror the dependency graph by hand |
| Separate repositories | Repository boundary | None | Version bumps and releases for shared libraries |
Illustrative estimate (assumed numbers, not a benchmark). Formula: monthly net = R × (T_before − T_after) × c − M × h. One-time onboarding cost is O × h.
Assumed inputs:
- R = 600 pipeline runs per month
- T_before = 25 min per run
- T_after = 10 min per run
- M = 6 maintenance hours per month
- O = 40 onboarding hours
That saves 600 × 15 = 9,000 runner-minutes, or 150 runner-hours, per month. It costs 6 engineer-hours per month plus 40 hours up front. Whether the change pays off depends on your c (runner cost per minute) and h (engineer cost per hour). The estimate also leaves out developer wait time, which often matters more than runner cost.
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 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.