Gradle
Software Mile uses Gradle to make builds fast, reproducible, and automated – the unglamorous infrastructure that decides whether a team ships smoothly or fights its own toolchain.
Build Tooling That Gets Out of the Way
- Gradle build configuration for Java, Kotlin, and Android projects
- Reproducible builds across developer machines and CI
- Build performance tuning and dependency management that scales with the codebase
- CI/CD integration so builds and releases are routine, not rituals
When builds are slow or flaky, everything downstream suffers. Tell us about your build pain and we will make it boring again.
When a Maven to Gradle Move Is Worth It
If your Maven build finishes quickly, the module layout is stable and nobody complains, migration mostly buys flexibility you may not need. It also puts your custom plugins and release steps back on the table for review, and some of them will need rewriting. Gradle earns the switch when there is real build logic to express: conditional packaging, many modules sharing conventions, Android, or Kotlin multiplatform, and Maven’s XML is fighting you at every turn.
Android is the clear case, since Gradle is the supported path there and the decision is already made for you. Everywhere else, treat migration as a project with a payoff you can name. If you cannot say what gets faster or simpler afterward, keep the build you have and spend the time on the application.
What Actually Makes a Gradle Build Slow?
Tuning is easy to start in the wrong place. Before touching configuration, get a build scan or a profile and find out where the time actually goes, because the answer is frequently not compilation. The usual culprits are a module structure that forces everything to rebuild, tests that all run inside one task, and caching that was never switched on.
- Configuration time. Scripts that resolve dependencies or run logic during configuration make every task pay for it. The configuration cache exists for exactly this.
- No build cache. Local and remote caching lets unchanged modules come back from cache on developer machines and in CI alike.
- Coarse modules. One large module means one large recompile. Splitting along real boundaries does more for build time than most flags will.
- Serial test execution. Test parallelism and sensible task splitting usually beat any compiler tweak you were considering.
- Guessed memory settings. JVM heap and daemon options set once years ago cause swapping and slowdowns nobody can explain.
Why Does the Build Pass Locally and Fail in CI?
Almost always because the build is not hermetic. Something on the developer machine is not in the repository: a JDK version, an environment variable, a locally installed artifact, or a dependency that resolves differently today than it did last week. Dynamic version ranges and snapshot dependencies make the build a function of time as well as of your code.
Pinning the toolchain, locking dependency versions, and declaring repositories explicitly removes most of this class of problem. It is unglamorous work, and it is what separates a red build that means something real from a red build that means “try again.”
Keeping Dependencies Under Control
In a multi-module project, version drift arrives quietly. One module upgrades a library, another does not, and a transitive conflict resolves to something nobody chose. Version catalogs give you one declared list of versions, dependency locking makes resolution reproducible, and convention plugins let every module inherit the same rules from one place.
This matters for security review too. If you cannot produce a list of what your application actually ships, you cannot answer a vulnerability question quickly. A build that generates that list on demand turns an urgent question into a routine one.
Where Outside Help Fits
Build engineering is a specialty most teams need occasionally and few need permanently. Watch the behavioral signs more than the numbers: developers running with tests disabled, people rerunning CI hoping for green, releases only one person knows how to perform. Habits like those usually cost more than the build time itself, because they quietly erode trust in the pipeline.
Work of this kind can be scoped as a short engagement, or run alongside your own developers through staff augmentation. Either way the conventions should end up written down and owned by your team.
What the Wrapper Actually Pins
gradle/wrapper/gradle-wrapper.properties holds a distributionUrl, and the gradlew script downloads and unpacks that distribution into the user home on first use. Committing the wrapper is what makes ./gradlew mean the same thing on every machine; invoking a system-installed gradle instead quietly opts out of it.
The wrapper pins Gradle and nothing else. Upgrading Gradle itself is rarely the hard part. What sets the pace is plugins: one compiled against a removed API fails during configuration, and until it publishes a compatible release the options are to stay on the old Gradle version or stop using the plugin. The two Java versions in play are independent of the wrapper and of each other:
- The JDK that runs Gradle itself. Recent major versions have raised that floor, most recently to Java 17 for Gradle 9.
- The JDK your code is compiled and tested with, declared by the Java toolchain, which does not have to be the one running Gradle.
What the Configuration Cache Demands
The configuration cache serializes the task graph once configuration has run and replays it on later builds when nothing that fed configuration changed. That last part trips people up. Reading an environment variable, a system property, or a file during configuration registers that value as a cache input, so a script that consults the clock or a mutable variable misses every time and never says so out loud.
It also enforces rules that older build logic breaks: a task action cannot reach back to the Project object at execution time, cannot capture a Task instance in a closure, and cannot hold state that will not serialize. Providers and value sources are the supported way to read outside state, because they declare the dependency instead of hiding it. Three practical points before you switch it on:
- The problems report is written under build/reports/configuration-cache and groups failures by task, naming the field and type that could not be stored along with the script or plugin that introduced it.
- A task that cannot be fixed yet can be marked with notCompatibleWithConfigurationCache and a reason, so the rest of the build still gets the cache instead of the whole feature being turned off.
- The configuration cache and the build cache solve different problems. One skips configuration, the other skips task execution, and enabling either does nothing for the other.
Cache Hits You Are Not Getting
A cacheable task produces an entry keyed by a hash of everything declared: input files, input properties, the task's own classpath, and its implementation class. Change any one and the key changes. When a task you expected to come back from cache runs anyway, org.gradle.caching.debug=true prints the individual hashes that went into the key, and comparing that output across two builds names the input that moved instead of leaving you to guess.
The causes are mundane and mostly fixable: an absolute path captured in an input property, a timestamp written into a manifest, an annotation processor that is not itself cacheable. Some tasks, though, can never produce an entry at all:
- Tasks with no declared outputs. There is nothing to store, so there is nothing to restore.
- Tasks whose output directory overlaps another task's. Gradle disables caching for both, because it cannot tell which task owns which file.
- Tasks not marked cacheable. @CacheableTask is opt-in, and a custom task without it re-executes even when everything around it hits.
- Tasks with an input that changes on every run: a build number, a commit hash, a timestamp. The key is different by construction.
Reading the Dependency Graph
Gradle resolves a version conflict by selecting the highest version present in the graph. Maven picks the nearest declaration to the root instead, so a module that behaved one way under Maven can land on a different version under Gradle with no dependency declaration changed by anyone.
dependencies prints the resolved graph; dependencyInsight explains why one module ended up on the version it did, including which constraint, platform, or transitive path forced it. Both work per configuration, and compileClasspath and runtimeClasspath routinely resolve differently, so ask the one that matches your question. Two habits are worth the effort:
- Write the resolved graph to a file and diff it between commits. A version change nobody made is usually a transitive upgrade arriving through a version range or a platform bump.
- Treat api and implementation as a technical choice, not a stylistic one. An api dependency lands on the consumer's compile classpath, so an api declaration deep in the module graph widens what every downstream module sees and what has to recompile when it changes.
One Build, Several JVMs
A Gradle build does not run in one JVM. The daemon is one process, test workers are separate processes, and Kotlin compilation runs in a daemon of its own. Each is sized independently, and pressure in one is invisible from the others: an OutOfMemoryError in a test worker tells you nothing about how the daemon is configured.
Daemons persist between builds and idle out after three hours by default. A daemon started under the old settings keeps serving until something invalidates it, which is why a memory change sometimes appears to do nothing at all. --status lists the daemons currently running and their state, and --stop ends them. The three settings that matter sit in three different places:
- Daemon: org.gradle.jvmargs in gradle.properties. Changing it starts a new daemon rather than resizing the running one, so the previous process lingers until it idles out.
- Test workers: maxHeapSize on the Test task, multiplied by maxParallelForks. Four forks at 2 GB each is 8 GB of test processes running alongside the daemon, not instead of it.
- Kotlin compilation: kotlin.daemon.jvmargs, separate again. kapt is the memory-hungry part, since it generates Java stubs for annotation processors to read; KSP reads Kotlin declarations directly and skips that step.
Frequently Asked Questions
Is Gradle only for Java and Android projects?
No. Gradle has built-in support for Java, Kotlin, Groovy, Scala, Android, C++, and Swift, and anything outside that list can be driven by custom tasks, so builds that package documentation, run Node tooling, or produce container images are ordinary uses of it. What it is not is a CI server, an artifact repository, or a package manager. It resolves dependencies from Maven and Ivy repositories that someone else hosts, and it runs wherever you invoke it.
What has to be installed before a Gradle build will run?
A JDK, found through JAVA_HOME or java on PATH. If the wrapper is committed, Gradle itself does not need to be installed anywhere. On a restricted network, point distributionUrl at an internal mirror so the wrapper is not reaching out to services.gradle.org, and mirror the dependency repositories the same way, otherwise the build depends on outbound access you may not have in CI.
Can a build compile against a different Java version than the one running Gradle?
Yes, and that is what the toolchain declaration is for. If no matching JDK is installed on the machine, a toolchain resolver plugin can provision one from a configured download source; with no resolver configured, the build fails with a message naming the version it could not find rather than falling back to something close. Existing JDK installations can also be pointed at explicitly through org.gradle.java.installations.paths.
Groovy DSL or Kotlin DSL for build scripts?
Kotlin DSL files, build.gradle.kts, get type checking and real IDE completion, which matters most in builds with custom logic in them. Groovy scripts are more forgiving and have far more copy-paste examples behind them, and their first-run cost is lower because Kotlin scripts have to be compiled. The two can coexist in one build, module by module, so a change of DSL does not have to happen all at once.
When is a remote build cache worth running?
When enough people and machines build the same commits that one run's work can serve another's: a CI fleet with several agents, or a team where everyone rebuilds the mainline after each merge. With one developer and one CI agent, the local cache already covers most of the benefit, and the remote node adds a service to operate and monitor for very little return.
What do you need from us to work on our build?
Repository access, whatever credentials the build needs to reach private repositories, and the CI configuration it runs under. Beyond that, a reproduction: the exact command, the branch, a clean checkout, and whether the problem shows up only in CI. The decisions stay yours, specifically which Java version you target, whether the build has to work with no network access, and whether releases are cut from CI or from a developer machine.