Gradle build cache: monorepo, buildSrc and included builds
In a big Gradle repo, an edit should rebuild only the part you edited. FosterStack Cache is a shared cache server, so in our runs the parts you did not change came from the cache.
In our runs, a module, buildSrc or an included build came from the cache unless its own code changed, or something it uses changed its public methods.
The short answer
- Change one module's code and, in our runs, the other modules still came from the cache.
- Change a module's public methods and every module that uses it rebuilt.
- A teammate who made the same change got everything from the cache.
- In our runs,
buildSrcand an included build each saved their owncompileJavaresult to the cache, and later builds took it back from the cache. - In our runs the cache was set up once, in the root
settings.gradle.ktsandgradle.properties. Nothing extra was needed inbuildSrcor in the included build's build file.
Our advice, not a test result
- In a big monorepo, a small, stable shared module with few public changes keeps more of the build cached.
- In a real team, let only CI upload (
isPush) and have developers read. That setup, with a read-only login, is on the CI writes, developers read page.
Set it up
This is the exact settings.gradle.kts we ran for the four-module build. Replace the address with your server's. We used 127.0.0.1 over plain HTTP because the test ran on one machine; use HTTPS across a network.
buildCache {
local { isEnabled = false }
remote<HttpBuildCache> {
url = uri("http://127.0.0.1:18140/")
isPush = true
isAllowInsecureProtocol = true
}
}
And this is the whole gradle.properties:
org.gradle.caching=true
org.gradle.parallel=true
What a multi-module run shows
A build with four modules. core has no dependencies. util and api use core. app uses util and api. Each module has 12 small Java classes. The table shows what each module's compile step did.
| Run | core | util | api | app |
|---|---|---|---|---|
| 1. First build, as CI would do it | ran | ran | ran | ran |
| 2. Another machine, same code | from cache | from cache | from cache | from cache |
3. Edit inside a method in core | ran | from cache | from cache | from cache |
| 4. A teammate makes the same edit | from cache | from cache | from cache | from cache |
5. Add a public method to core | ran | ran | ran | ran |
6. Edit only app | from cache | from cache | from cache | ran |
"Ran" means Gradle did the work and uploaded the result. "From cache" means Gradle downloaded it instead, and printed FROM-CACHE next to the task. This is what run 3 printed:
> Task :core:compileJava
> Task :util:compileJava FROM-CACHE
> Task :api:compileJava FROM-CACHE
> Task :app:compileJava FROM-CACHE
- Edits inside a method stayed local (run 3). Gradle compares the public shape of a module, not every byte, when it decides whether a module that depends on it must rebuild. A change inside a method body left the other three modules' inputs the same, so they came from the cache.
- Changes to public methods spread (run 5). All four modules ran.
- The first person paid, everyone else downloaded (runs 4 and 6). The edit in run 3 was uploaded once. In run 4 a separate copy of the project made the same edit and ran nothing.
Only the compile step is shown here, because it is the step that showed the pattern clearly in a small project. The jar steps in this build are not cacheable by default in Gradle, so they ran every time. The project is small, so we are not quoting time saved.
Does Gradle cache buildSrc and included builds?
Yes, in our runs. The build had three parts: a buildSrc folder with one Java class, an included build called lib with one Java class, and an app project that uses lib. The columns show the compileJava task of each part.
| Run | buildSrc | lib (included build) | app |
|---|---|---|---|
| 1. First build, as CI would do it | ran | ran | ran |
| 2. Fresh copy, same code | FROM-CACHE | FROM-CACHE | FROM-CACHE |
3. Edit buildSrc | ran | FROM-CACHE | FROM-CACHE |
4. Edit inside a method in lib | FROM-CACHE | ran | FROM-CACHE |
5. Add a public method to lib | FROM-CACHE | ran | ran |
6. Edit only app | FROM-CACHE | FROM-CACHE | ran |
These are three lines from run 2, with the lines between them left out:
> Task :buildSrc:compileJava FROM-CACHE
...
> Task :lib:compileJava FROM-CACHE
...
> Task :app:compileJava FROM-CACHE
- buildSrc was cached like any other project. A change to it recompiled it (run 3). It did not recompile
liborappin our run. - An included build was cached too. Its tasks show up as
:lib:compileJavaand came from the cache in run 2. - A change inside a method did not spread (run 4); a new public method did (run 5). That matches the module runs above.
We tried one small class in each part. We did not test a buildSrc that holds plugins, convention plugins, or other languages, and we did not test an included build that is itself a large multi-module build.
The files we used. Root settings.gradle.kts, with the address for a test on one machine over plain HTTP; use your server's address and HTTPS across a network:
rootProject.name = "composite"
includeBuild("lib")
include(":app")
buildCache {
local { isEnabled = false }
remote<HttpBuildCache> {
url = uri("http://127.0.0.1:18220/")
isPush = true
isAllowInsecureProtocol = true
}
}
The root gradle.properties has one line, org.gradle.caching=true. The root build.gradle.kts has one line that prints a value from the buildSrc class. lib/settings.gradle.kts is rootProject.name = "lib". buildSrc/build.gradle.kts is plugins { java }. lib/build.gradle.kts is plugins { `java-library` } with group = "demo", and app/build.gradle.kts adds implementation("demo:lib").
If you do not see FROM-CACHE
Work through why the hit rate is low. To check the server itself, see your first 15 minutes, for logins see authentication, and to see what Gradle stores, see what Gradle stores in the build cache.
How we checked
All the runs above used Gradle 9.8.0 with FosterStack Cache 0.2.1 on macOS (arm64), Java 27. Every run used its own copy of the project folder, an empty Gradle home folder, and the local cache switched off. The only Gradle state the runs shared was the cache server. Every build finished with BUILD SUCCESSFUL.
Four-module build: six runs, each gradle build --console=plain --no-daemon. An earlier set of runs started from a project folder that still held build output left over from an earlier test. We repeated all six runs from a clean copy and got the same results, down to the same entry count on the server. The table shows the clean runs.
buildSrc and included build: six runs, each gradle :app:build --console=plain --no-daemon, with Java 27 from Homebrew and the same Gradle install and Java for every run. Runs 3 to 6 each made one edit on a fresh copy of the original code; the edits were not stacked.
The runs above were on a Mac with 0.2.1. We ran both tables again on a GitHub-hosted Linux machine with FosterStack Cache 0.2.2, Gradle 9.8.0 and Java 21, each run on a fresh copy of the project and with an empty Gradle home. Every cell of both tables came out the same, and in the four-module build the jar steps ran every time. Run 5 (“all four ran”) only happens when app can see core’s public code. In the project we built for this run, util and api are java-library modules that each declare api(project(":core")), so app sees core through them. The run, the build files and the log are public.