What @nx/gradle does and who it is for

@nx/gradle connects an existing Gradle multi-project build to Nx without moving build logic out of Gradle. Gradle remains the source of truth: settings.gradle(.kts) declares the subprojects, each build.gradle(.kts) applies the Spring Boot and dependency-management plugins, and the Gradle wrapper still compiles classes, runs tests, and packages executable jars. The plugin reads that build, creates an Nx project for each Gradle subproject, and exposes Gradle tasks as Nx targets. You do not write project.json files. Adding a module in settings.gradle adds it to Nx as well, so the two definitions cannot drift apart.

Teams adopt it to get one project graph and one affected-only CI across the whole repository. In a typical setup, a React admin UI and a Node BFF already run through Nx, while a dozen Spring Boot services build with Gradle in a separate pipeline. Without a shared graph, a change to a shared Java DTO library cannot trigger only the services that consume it, and CI tends to rebuild everything. With the plugin, nx affected follows Gradle's inter-project dependencies alongside the JavaScript ones.

The plugin helps most when platform and backend engineers maintain a polyglot monorepo. It adds little to a Java-only repository with one service.

CI pipeline
  └─> Nx: project graph · affected detection · task cache
        ├─> Gradle wrapper: task graph · build cache · incremental compilation
        │     ├─ Spring Boot apps (orders-service, billing-service)
        │     └─ Shared libraries (common-dto, security-config)
        └─> JS projects (admin-ui, bff)
+--------------------------------------------------------------------------+
|                               CI pipeline                                |
+------------------------------------+-------------------------------------+
                                     |
                                     v
+--------------------------------------------------------------------------+
| Nx  (projects and targets inferred by @nx/gradle from the Gradle build)  |
|  project graph, affected detection, task cache                           |
+------------------+--------------------------------------------+----------+
                   |                                            |
                   v                                            v
+--------------------------------------------------+  +--------------------+
| Gradle wrapper (./gradlew)                       |  | JS projects        |
| task graph, build cache,                         |  |                    |
| incremental compilation, test, bootJar           |  |  +--------------+  |
|                                                  |  |  | admin-ui     |  |
|  +--------------------+  +--------------------+  |  |  +--------------+  |
|  | Spring Boot apps   |  | Shared libraries   |  |  |  +--------------+  |
|  |  orders-service    |->|  common-dto        |  |  |  | bff          |  |
|  |  billing-service   |  |  security-config   |  |  |  +--------------+  |
|  +--------------------+  +--------------------+  |  |                    |
|  apps -> libs: project(...) dependencies         |  |                    |
+--------------------------------------------------+  +--------------------+

CI calls Nx, which uses one project graph to pick affected projects and delegates Spring Boot builds to the Gradle wrapper while running JS projects directly.

Structuring the repo and adding the Gradle plugin

Keep a single Gradle build at the repository root, alongside package.json and nx.json. The root settings.gradle.kts sets rootProject.name = "platform" and calls include(":apps:orders-service", ":apps:billing-service", ":libs:common-dto", ":libs:security-config"). JS projects stay outside Gradle in their own directories, such as apps/web, and Gradle ignores them. A single root build gives Nx one place to discover every JVM project. It also lets Gradle resolve cross-project references without publishing artifacts to a repository first.

Declare library usage the normal Gradle way. In apps/orders-service/build.gradle.kts, add implementation(project(":libs:common-dto")) to the dependencies block. Nx reads these project references to draw graph edges. A change to common-dto then marks orders-service as affected, while billing-service is untouched if it does not depend on that library.

Commit the Gradle wrapper: gradlew, gradlew.bat, and gradle/wrapper/. Nx runs Gradle through the wrapper, so it always uses the Gradle version pinned in gradle-wrapper.properties and never whatever happens to be installed on a laptop or CI image. Without the wrapper, the same commit can produce different task graphs on different machines.

Add the plugin with npx nx add @nx/gradle. This installs the package and registers it in nx.json under "plugins": ["@nx/gradle"]. Pin nx and @nx/gradle to the same exact version in package.json, with no caret, because the two are released in lockstep.

Plugin options and the names of inferred targets have changed between Nx releases. Before you write CI scripts against specific target names, check the @nx/gradle documentation for the version you installed.

// settings.gradle.kts (repo root, next to package.json and nx.json)
// One Gradle build so Nx discovers every JVM project from one place.
rootProject.name = "platform"

include(
    ":apps:orders-service",
    ":apps:billing-service",
    ":libs:common-dto",
    ":libs:security-config"
)
// apps/web is a JS project and is not included, so Gradle ignores it.

// apps/orders-service/build.gradle.kts
// A normal project reference. Nx reads it as a graph edge, so a change
// in common-dto marks orders-service as affected.
dependencies {
    implementation(project(":libs:common-dto"))
    implementation(project(":libs:security-config"))
}

// nx.json (excerpt), written by `npx nx add @nx/gradle`
{
  "plugins": ["@nx/gradle"]
}

// Committed so Nx always runs the pinned Gradle version:
//   gradlew, gradlew.bat, gradle/wrapper/gradle-wrapper.properties
// Inferred target names and plugin options vary by Nx release;
// check the @nx/gradle docs for the version you installed.

A root settings.gradle.kts that includes Spring Boot apps and shared libraries, a library dependency declared with implementation(project(...)) that Nx turns into a graph edge, and the @nx/gradle entry in nx.json.

How inference builds the project graph

When Nx computes the project graph, @nx/gradle invokes Gradle to evaluate your build: settings, every build.gradle(.kts), and applied plugins. From that configured model it reads which projects exist, which tasks each registers, and how projects depend on each other. A implementation project(":libs:shared-model") declaration in a service becomes a static edge from that service to the library in the Nx graph. Nx never parses your build scripts itself; it trusts what Gradle reports. The graph is therefore only as accurate as your Gradle configuration.

To inspect the result, run nx show project order-service and review the inferred targets, their inputs and outputs, and the underlying Gradle task each one calls. Then run nx graph and confirm the edges match your mental model. A missing edge usually means a dependency was declared in a way Gradle does not expose as a project dependency.

Next, verify affected detection. On a branch, edit a class in the shared library and run nx show projects --affected --base=main. The library and every Spring Boot service that depends on it should appear. Services that do not depend on it should not. Run this check before you trust affected-only CI.

Inference has two costs:

  • Configuration time. Every graph computation pays for Gradle configuration. If the configuration phase is slow, or has side effects such as network calls or file generation, every nx command inherits that cost and those side effects. Keep configuration cheap and pure.
  • Unexpected targets. Heavy use of convention plugins or dynamically registered tasks can produce targets you did not expect. Review nx show project output after you change shared build logic.
#!/usr/bin/env bash
# Assumes services/order-service/build.gradle.kts declares:
#   implementation(project(":libs:shared-model"))
set -euo pipefail

# 1. Inspect what the plugin inferred for one service: its targets,
#    their inputs and outputs, and the Gradle task each one runs.
nx show project order-service

# Open the project graph and check that order-service -> shared-model
# exists as an edge. A missing edge points to a dependency Gradle does
# not report as a project dependency.
nx graph

# 2. Verify affected detection on a throwaway branch.
git switch -c verify-affected

# A trivial edit to a class in the shared library.
echo '// touch for affected check' >> \
  libs/shared-model/src/main/java/com/example/shared/OrderDto.java

# Without a commit, Nx also counts uncommitted changes, so this edit is
# picked up. Expected output: shared-model plus every service that
# depends on it, such as order-service. Unrelated services must not appear.
nx show projects --affected --base=main

# 3. Clean up.
git checkout -- libs/shared-model
git switch main
git branch -D verify-affected

# Note: every command above makes Nx compute the graph, which runs
# Gradle configuration. Slow or side-effectful configuration makes each
# of these commands slow too.

Inspects the inferred targets and edges for a Spring Boot service, then confirms that editing a shared library marks its dependent services as affected.

Mapping Spring Boot tasks to Nx targets and configuring caching

Gradle task Nx target Cacheable Outputs to declare
build build Yes build/libs, build/classes, build/resources
test test Yes build/test-results, build/reports/tests
bootJar bootJar Yes build/libs (the executable jar)
bootRun bootRun No None. It is a long-running process, and caching it is meaningless.

Inferred targets carry defaults, but confirm them with nx show project. To adjust them, use targetDefaults in nx.json for repo-wide rules or project.json for a single service.

Define a namedInputs entry such as sharedConfig in nx.json. It should cover:

  • Root-level Gradle files: gradle.properties, version catalogs and buildSrc.
  • Your OpenAPI spec directory.
  • An env input for variables that change build output.
  • A runtime input that runs java -version, so a JDK upgrade invalidates cached results.

If a service consumes a spec that lives in a project without a Gradle dependency edge, list that project in implicitDependencies.

Gradle's build cache and incremental compilation avoid redundant work inside a single Gradle invocation, and they key on task-level inputs. Nx operates one level up. It skips whole Gradle invocations for unaffected projects, restores outputs from a remote cache shared across CI agents, and replays terminal logs. The two layers stack: Nx decides whether to call Gradle at all, and Gradle optimizes the calls that remain.

The cost of getting this wrong is silent. If you omit an output path, a cache hit reports success but leaves no jar on disk, and the artifact step downstream fails or ships an old file. If you omit an input, a changed OpenAPI spec or JDK produces a cache hit for code that was never rebuilt against it.

{
  "namedInputs": {
    "default": ["{projectRoot}/**/*", "sharedConfig"],
    "sharedConfig": [
      "{workspaceRoot}/gradle.properties",
      "{workspaceRoot}/gradle/libs.versions.toml",
      "{workspaceRoot}/buildSrc/**/*",
      "{workspaceRoot}/api-specs/**/*.yaml",
      { "env": "SPRING_PROFILES_ACTIVE" },
      { "runtime": "java -version 2>&1" }
    ]
  },
  "targetDefaults": {
    "build": {
      "cache": true,
      "inputs": ["default", "^default"],
      "outputs": [
        "{projectRoot}/build/libs",
        "{projectRoot}/build/classes",
        "{projectRoot}/build/resources"
      ]
    },
    "test": {
      "cache": true,
      "inputs": ["default", "^default"],
      "outputs": [
        "{projectRoot}/build/test-results",
        "{projectRoot}/build/reports/tests"
      ]
    },
    "bootJar": {
      "cache": true,
      "inputs": ["default", "^default"],
      "outputs": ["{projectRoot}/build/libs"]
    },
    "bootRun": {
      "cache": false
    }
  }
}

An nx.json that adds a sharedConfig named input covering Gradle files, OpenAPI specs, an env variable and the JDK version, and declares outputs per target with bootRun left uncached.

A CI pipeline with nx affected and per-service artifacts

The pipeline has a fixed order, and most of the steps exist to protect the comparison that nx affected depends on.

  1. Check out with full history. Fetch the whole history, or at least enough to include the merge base with the target branch. Nx diffs base...head. In a shallow clone the base commit is often missing, and the result is wrong in one of two ways: everything is marked affected, or the comparison resolves against an unintended commit and misses changed projects. Neither case produces an error.
  2. Set base and head explicitly. Pass --base and --head, or export NX_BASE and NX_HEAD. On pull requests the base should be the merge base with the target branch. On the main branch it should be the last commit that passed CI, not HEAD~1, because HEAD~1 drops changes from earlier failed runs.
  3. Set up the JDK and restore caches. Restore the Gradle caches and the Nx cache, and key both on the JDK version.
  4. Fail fast on graph errors. Run nx show projects before anything else. If Gradle inference fails, this step stops the job before it spends time on tasks.
  5. Run nx affected -t lint test build.

To package, run bootJar or bootBuildImage only for affected services. Collect the list with nx show projects --affected --json, filter it to deployable applications, and pass it to the deploy steps as a job output. Each service then gets its own artifact named after its project, and the deploy steps never infer the list on their own.

Run integration and contract tests on every build, outside the affected logic. They cover failures that the project graph cannot detect, such as a schema change in a shared database or a broken consumer contract with a service in another repository.

When to avoid it and the alternatives

Running two orchestrators has its own costs. Nx decides what runs, and Gradle decides how it runs. When a task is skipped, rebuilt unexpectedly, or served a stale output, you first have to work out whether the cause is Nx's cache key, Gradle's up-to-date check, or the Gradle build cache. Engineers need a working model of both tools to debug confidently.

Affected detection is only as good as the declared graph. It follows Gradle project dependencies and any Nx implicitDependencies. It cannot see an HTTP or messaging contract between two Spring Boot services that share no code. The plugin also infers targets from Gradle, so an Nx or plugin upgrade can rename or reshape those targets. Pin versions and check the inferred targets after each upgrade.

If the repo is Java-only, a well-tuned Gradle build may get you most of the benefit. Up-to-date checks and a remote build cache already skip unchanged work. Nx adds the most value when the same repo also holds frontends, Node tooling, or other ecosystems that need one shared graph.

Approach Affected detection Polyglot support Duplicated build logic Operational overhead
Plain Gradle, build cache, configuration-on-demand None at CI level; cache hits skip unchanged tasks JVM-centric None Lowest
Gradle with path-based CI filters Coarse; transitive dependencies must be hand-encoded Any language, per path Dependency knowledge copied into CI config Low, grows as filters drift
Nx with @nx/gradle Derived from the Gradle project graph Strong Minimal; targets inferred Second toolchain, Node in CI, plugin upgrades
Hand-written Nx run-commands targets Depends on manually declared dependencies Strong High; mirrors Gradle tasks High; manual sync with Gradle

Impact on cost and engineering time

Savings come from four places. Affected-only runs skip services a pull request cannot have changed, so CI minutes scale with the size of the change instead of the size of the repo. Cache hits mean a rerun after a flaky test, or a branch rebased onto an unchanged service, reuses earlier outputs. One project graph spanning Java and JS removes the hand-maintained lists of "if the shared DTO module changes, rebuild these" that drift over time. And because targets are inferred from Gradle, there is no second set of build definitions to keep in sync.

Illustrative estimate (assumed numbers, not a benchmark)

Assumed inputs:

  • 8 services
  • 10 minutes average build and test per service
  • 60% of PRs touch one service; 40% touch a shared module that affects all services
  • 2 minutes of added overhead per run for Node setup and graph computation

Formula: affected minutes per PR = (share_single × T) + (share_shared × N × T) + overhead.

Here that is (0.6 × 10) + (0.4 × 80) + 2 = 40 minutes per PR, compared with 80 for a full build. The estimate ignores cache hits, so it understates the savings.

Against that, budget engineering time for:

  • Initial setup. Adding the plugin and wiring CI.
  • Upgrade checks. Re-verifying inferred targets after every Nx or Gradle upgrade, because inference rules can change.
  • CI git configuration. Getting fetch depth and base SHAs right, since a shallow clone silently breaks affected detection.
  • Two-layer debugging. When a task misbehaves, the cause can sit in Nx or in Gradle, and engineers must learn both to trace it.

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.