What does the nx gradle plugin do, and what does Gradle still own?
The nx gradle plugin does not replace Gradle. Gradle remains the source of truth for Java compilation, dependency resolution, test execution and Spring Boot packaging. Your build.gradle or build.gradle.kts files, the Spring Boot plugin configuration and the version catalog stay exactly as they are. Nx sits one layer above Gradle. It runs tasks across languages, works out which projects a change affects, and decides what CI needs to run.
To do this, the plugin builds its own picture of the repository from Gradle's view of it:
- Projects: It reads the multi-project structure declared in
settings.gradleand registers each Gradle project as an Nx project. - Targets: It exposes each project's Gradle tasks, such as
build,testandbootJar, as inferred targets. You do not write per-project Nx configuration for them. - Dependencies: It turns Gradle project dependencies into graph edges. If
orders-servicedeclares a dependency on:shared-lib, Nx records that edge.
Nx (orchestration, affected, CI)
┌───────────────┴────────────────┐
web-frontend Gradle build
(TypeScript) ┌────────────────┼────────────────┐
orders-service billing-service shared-lib
└────────────────┴──────►─────────┘
depends on
This division explains why the graph matters. Affected detection and task ordering are only as correct as these edges. If an edge is missing, Nx can skip testing a service whose library changed.
Before you rely on anything built on the graph, open nx graph. Check that every Gradle project appears in it and that each dependency on the shared library shows up as an edge.
┌────────────────────────────────────────────────────────────────────────────┐
│ Nx: runs tasks across languages, computes affected, orchestrates CI │
│ Project graph inferred by the nx gradle plugin (verify: nx graph) │
└──────┬───────────────────────────────────────┬─────────────────────────────┘
│ │
▼ ▼
┌──────────────┐ ┌──────────────────────────────────────────────────────────┐
│ web-frontend │ │ Gradle build (settings.gradle) │
│ (TypeScript) │ │ compile, dependency resolution, test, bootJar │
│ build, test │ │ │
└──────────────┘ │ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ orders-service │ │ billing-service│ │ shared-lib │ │
│ │ (Spring Boot) │ │ (Spring Boot) │ │ (java library) │ │
│ └───────┬────────┘ └───────┬────────┘ └───────▲────────┘ │
│ │ │ │ │
│ └──────────────────┴──────────────────┘ │
│ depends on :shared-lib (each becomes an Nx edge) │
└──────────────────────────────────────────────────────────┘
Nx orchestrates a TypeScript frontend and a Gradle build. Gradle still owns compilation and packaging, and each service's dependency on shared-lib becomes an edge in the Nx graph.
Who should use Nx with Gradle, and when should you avoid it?
Nx with Gradle pays off when several language ecosystems share one repository and one CI pipeline. A typical case is Spring Boot services next to a TypeScript frontend, plus generated API clients or end-to-end tests that span both. In that setup, one affected-project calculation and one task cache across languages can replace separate per-ecosystem scripts that guess what changed.
The costs are real, and they decide whether adoption is worth it:
- Two mental models and two toolchains. Engineers need to understand Gradle's configuration and task model and Nx's targets and inputs. When a build misbehaves, someone must work out which layer is responsible.
- Wrapped failures. A Gradle stack trace arrives inside Nx output. It is still readable, but it adds a layer to scan through, especially in CI logs.
- Graph computation overhead. To build its project graph, Nx asks Gradle about the projects and their dependencies. That adds startup time, which you should measure in your own repo before and after adoption rather than assume.
- Moving plugin options. The plugin's configuration options have changed between releases. Pin versions and read the changelog before you upgrade.
- IDE bypass. IntelliJ and similar IDEs import the Gradle build directly. Builds and test runs started from the IDE never touch the Nx cache, so local behaviour can differ from CI.
A repository that is almost entirely Java may be simpler with Gradle alone. Gradle's own build cache and configuration features already cover most of what Nx would add there.
How do you set up Spring Boot services and a shared library in an Nx workspace?
A workable layout keeps Gradle at the repository root, next to nx.json and package.json. The services sit at apps/orders-service and apps/billing-service, the shared Java library at libs/shared-domain, and the existing TypeScript frontend stays at apps/web. The root settings.gradle.kts includes the three Java projects and nothing else. Gradle never needs to know the frontend exists. Keeping a single Gradle build, rather than one per service, means Gradle resolves inter-project dependencies itself, and the plugin reads one coherent project model.
Generate the Gradle wrapper once and commit gradlew, gradlew.bat and the gradle/wrapper directory. Nx should call ./gradlew, not a gradle binary on someone's PATH. That way every laptop and CI runner uses the Gradle version pinned in gradle-wrapper.properties. Mismatched Gradle versions are a common source of "works locally" failures and cache misses.
Each service declares the library in its dependencies block as implementation(project(":libs:shared-domain")). This project dependency is what the plugin turns into a graph edge. If you depend on a published artifact coordinate instead, Nx has no link to the source.
Add the plugin with nx add @nx/gradle, which registers it in the plugins array of nx.json. Pin the exact plugin version in package.json and keep it aligned with your Nx version. The plugin's options and the targets it infers have changed between releases, so check every snippet here against the documentation for the version you install.
Then run nx graph and confirm that both services point at shared-domain. A missing edge almost always means the dependency was declared as an external coordinate rather than as a project dependency.
#!/usr/bin/env bash
# Run from the repo root, next to nx.json and package.json.
set -euo pipefail
# 1. One Gradle build for the whole repo: include only the Java projects.
# apps/web (TypeScript) is deliberately absent; Gradle never sees it.
cat > settings.gradle.kts <<'EOF'
rootProject.name = "shop-monorepo"
include(":apps:orders-service")
include(":apps:billing-service")
include(":libs:shared-domain")
EOF
# 2. Each service depends on the library as a *project*, not a Maven coordinate.
# This is what the Nx plugin turns into a graph edge.
# apps/orders-service/build.gradle.kts (same in billing-service):
# dependencies {
# implementation(project(":libs:shared-domain"))
# }
# 3. Generate the wrapper once (the only time a local Gradle is needed),
# then commit it so Nx, laptops and CI all run the pinned ./gradlew.
gradle wrapper
git add gradlew gradlew.bat gradle/wrapper settings.gradle.kts
./gradlew projects # sanity check: lists the three included projects
# 4. Register the plugin in the plugins array of nx.json.
npx nx add @nx/gradle
# Then edit package.json so @nx/gradle has an exact version (no ^ or ~)
# that matches your nx version, and reinstall.
# 5. Inspect the project graph: orders-service and billing-service
# should both have an edge to shared-domain. A missing edge usually
# means a dependency was declared as an external coordinate.
npx nx graph
Shell walkthrough that creates a root settings.gradle.kts for two Spring Boot services and a shared library, commits the Gradle wrapper, adds @nx/gradle, and checks the service-to-library edges with nx graph; verify the plugin steps against the docs for the version you install.
How do you build, test and package Java services through Nx?
Once the graph is correct, every Gradle task the plugin infers becomes an Nx target you can run by name. By default the target names match the Gradle task names, so you do not need to learn a second vocabulary. To build one service, run nx run orders-service:build. To run a task across the workspace, use nx run-many -t test. Nx runs each target by calling Gradle, so Gradle still resolves classpaths and compiles the code.
| Nx command | Gradle task invoked | Typical output |
|---|---|---|
nx run orders-service:build |
build |
Compiled classes, test run, jars |
nx run orders-service:test |
test |
Test reports |
nx run orders-service:bootJar |
bootJar |
Executable Spring Boot jar |
nx run-many -t build |
build in every project |
Same, workspace-wide |
The more useful commands are nx affected -t build and nx affected -t test. Nx compares your branch against a base commit and maps the changed files to projects. It then walks the graph to find every project that depends on them. For example, suppose you edit a class in shared-domain. Nx selects shared-domain plus both services that depend on it. A service that does not depend on shared-domain is skipped. In a large repository, that is where most CI time is saved.
This only works if the Gradle dependency declarations are correct and the plugin can see them. Some dependencies are added by convention plugins, by dynamic build logic, or through custom configurations the plugin does not interpret. In those cases the graph can lose edges. When an edge is missing, affected reports nothing unusual. It skips the dependent service, and the CI run passes without that service being tested.
# Single project: Nx calls the Gradle task of the same name
nx run orders-service:build # runs Gradle 'build' for orders-service
nx run orders-service:test # runs Gradle 'test'
nx run orders-service:bootJar # runs Gradle 'bootJar' (executable Spring Boot jar)
# Whole workspace: run the target in every project that has it
nx run-many -t build
nx run-many -t test
# Only what changed since main, plus everything that depends on it
nx affected -t build --base=origin/main --head=HEAD
nx affected -t test --base=origin/main --head=HEAD
# Before trusting 'affected' in CI, confirm which projects Nx selects.
# After editing a class in shared-domain, this should list shared-domain
# and every service that consumes it.
nx show projects --affected --base=origin/main --head=HEAD
# Cross-check against what Gradle itself resolves. If orders-service
# shows shared-domain here but Nx did not list it as affected, the
# edge is missing from the Nx graph (for example, it was added by a
# convention plugin or a custom configuration).
./gradlew :orders-service:dependencies --configuration runtimeClasspath
# Inspect the project graph Nx built
nx graph --file=project-graph.json
Running inferred Gradle targets through Nx with run, run-many and affected, and checking the affected project list against Gradle's own dependency report to catch missing graph edges.
How does caching work across Nx and Gradle?
Two caches sit on top of each other. Nx hashes the inputs you declare for a target. On a match it replays the terminal output and restores the declared outputs without starting Gradle at all. If Nx does run Gradle, Gradle then checks each task's inputs and outputs itself. With its build cache enabled, it can also fetch task outputs from a local or remote store.
| Concern | Nx task cache | Gradle up-to-date checks / build cache |
|---|---|---|
| Unit of caching | Whole Nx target | Individual Gradle task |
| Knows inputs from | Your declarations only | Task definitions, classpaths, properties |
| Restores | Declared output paths and logs | Task outputs it tracks |
| Typical failure | Stale hit when an input is undeclared | Extra rebuilds; stale results are uncommon |
The two layers disagree when Nx's view is narrower than Gradle's. Suppose src/main/resources/application.yml is missing from the target's inputs. Nx gets a hit and restores a jar that still contains the old configuration. Gradle would have rebuilt it, but Nx never invoked Gradle. The same thing happens when a shared library changes and the service's inputs do not include its dependencies' production inputs.
Declare inputs honestly:
- Source sets and resources:
src/main/java,src/main/resources. - Build scripts:
build.gradle(.kts),settings.gradle(.kts),gradle.properties. - Dependency and toolchain pins:
gradle/libs.versions.tomlandgradle/wrapper/gradle-wrapper.properties.
Declare build/libs, build/test-results and build/reports as outputs so that a cache hit leaves real files on disk.
Some cases are risky:
- Environment variables: these change behavior invisibly unless you declare them as inputs.
- Generated code: OpenAPI generators and annotation processors need their specs listed as inputs.
- SNAPSHOT dependencies: their contents change with no change to any repository file.
For each target, decide which layer is authoritative. Where you cannot make the inputs explicit, disable Nx caching for that target and let Gradle decide.
{
"namedInputs": {
"sharedGlobals": [
"{workspaceRoot}/settings.gradle.kts",
"{workspaceRoot}/gradle.properties",
"{workspaceRoot}/gradle/libs.versions.toml",
"{workspaceRoot}/gradle/wrapper/gradle-wrapper.properties"
],
"production": [
"{projectRoot}/src/main/**/*",
"{projectRoot}/build.gradle.kts",
"sharedGlobals"
],
"default": ["{projectRoot}/**/*", "sharedGlobals"]
},
"targetDefaults": {
"build": {
"cache": true,
"inputs": ["production", "^production", { "env": "SPRING_PROFILES_ACTIVE" }],
"outputs": ["{projectRoot}/build/libs"]
},
"test": {
"cache": true,
"inputs": ["default", "^production"],
"outputs": ["{projectRoot}/build/test-results", "{projectRoot}/build/reports"]
},
"openApiGenerate": {
"cache": false
}
}
}
An nx.json fragment that hashes sources, resources such as application.yml, build scripts, the version catalog, wrapper properties, an env var and dependencies' production inputs, declares build outputs, and leaves the code-generation target uncached.
How do you connect Java and TypeScript tasks that Gradle cannot see?
Gradle models the JVM graph well, but it cannot see that a TypeScript client depends on a Spring Boot controller. If you join the two with shell glue, such as a script that boots the service, curls the spec and runs a generator, then neither affected detection nor the cache knows the relationship exists. A controller change will not mark the frontend as affected, and a cached client can silently drift from the API.
Make the spec a first-class output
Give the service an explicit target that writes the OpenAPI document to a fixed path inside the project, and declare that path in the target's outputs. Prefer generating the spec at build time over fetching it from a running server. A file produced from source is deterministic and cacheable. A spec fetched over HTTP depends on runtime state.
Depend on the spec, not the service
In the client library, add a generate target whose dependsOn points at the service's spec target. Its inputs should reference that spec file rather than the whole Java project. A Javadoc or test change that leaves the API unchanged produces an identical spec, so the client generation stays a cache hit. Declare the Java project as an implicit dependency of the client so nx affected crosses the boundary.
E2E tests need a running service
E2e targets should depend on the service's build output, start the packaged artifact, and wait for a health endpoint before running. Treat them as non-cacheable unless the inputs fully capture the backend's runtime environment, including databases and configuration. Otherwise a cache hit can certify behaviour nobody actually tested.
What does a CI pipeline look like, and how do you validate the setup?
Use one CI entry point: nx affected -t build test against the merge base of the target branch. Nx computes which projects changed, TypeScript or Java, and delegates Java targets to Gradle. A second, Gradle-only job would compute "affected" a different way and drift from Nx.
Treat the two caches separately. Gradle's dependency cache (~/.gradle/caches) holds downloaded artifacts and should be keyed on the build scripts and the version catalog. The Nx cache holds task outputs and should be keyed on Nx's own hashes. If you share one cache key, you will see stale dependency resolution or unnecessary cache churn.
Pin the JDK explicitly, either with Gradle toolchains or a provisioning step, so the runner image does not quietly choose the version. Ephemeral runners get little benefit from a warm daemon. Setting org.gradle.daemon=false in CI avoids leftover processes and memory surprises between tasks.
Where the time goes. You save time by skipping builds of unaffected services and by maintaining one pipeline. You pay for it with graph computation on every run, with debugging that spans two tools, and with the ongoing work of keeping each target's inputs and outputs accurate.
Validation checklist:
- Run the same target twice. The second run should report a cache hit.
- Change the shared library. Every Spring Boot service that depends on it should appear as affected.
- Edit a file under
src/main/resources. The next run should be a cache miss. - Open
nx graphand look for missing edges, especially Java-to-TypeScript links that Gradle cannot see.
IDE convention: Agree as a team on whether developers run tasks through Nx or through the IDE's Gradle integration. Running through the IDE bypasses the Nx cache, so local results can differ from CI.
What are the alternatives to the nx gradle plugin?
The plugin is not the only way to run Java services next to TypeScript code. The three alternatives below differ mainly in who tracks dependencies between projects: Gradle, your release process, or you.
| Option | Setup cost | Affected detection | Caching model | Operational overhead | Fits |
|---|---|---|---|---|---|
| Gradle alone (multi-project or composite builds, build cache) | Low if you already use Gradle | None at project level. Gradle runs the task graph and skips tasks through up-to-date checks and cache hits. | Gradle build cache, local or remote, keyed on declared task inputs | One tool, but TypeScript needs separate orchestration | Repos that are mostly or entirely JVM |
| Separate repositories per ecosystem | Low per repo | The repository boundary decides what is affected. Changes that cross repos are invisible to tooling. | Each repo's native cache | Versioned artifacts, coordinated releases, more CI configuration | Teams with independent release cycles and stable contracts |
Hand-written Nx run-commands targets that call the Gradle wrapper |
Medium, growing with project count | Only as accurate as the dependsOn and implicitDependencies you maintain by hand |
Nx cache over the inputs and outputs you declare. A missing input can cause a stale cache hit. | High. Every new module or dependency means editing Nx config. | Small workspaces with a handful of Java services |
The plugin's advantage is that it reads the project graph from Gradle itself. Hand-written targets drift as the build changes. Gradle alone stays correct, but it cannot see TypeScript tasks.
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, 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.