Pick a Scala project off the shelf - an open-source library, a tutorial, a hiring-test exercise - and you’ll almost certainly find an sbt file at its root. SBT is the de-facto build tool for Scala, and for most projects that's the right answer. However, at Box, the answer is Gradle.
We decided on Gradle because of the scale of the Scala monorepo we maintain:
- 360+ modules under a single Gradle build
- more than 100 engineers commit Scala code into it every year
- tens of commits land on main branch every working day
At this scale, the choice of a build system is less about language-native ergonomics and more about performance, and how well one tool keeps hundreds of modules - and dozens of cross-language teams - aligned.
In 2015, we started migrating our sbt-built codebase to Gradle, a process we finished in 2023. This post is the honest recap of that migration and years of running a Scala monorepo on Gradle: what motivated the move, what scaled well, what required real engineering investment to recreate from the sbt ecosystem, and where the trade-offs landed.

Motivation for migration
Note: The migration was decided while the monorepo was on sbt 0.13. Current sbt releases address some of the problems this post describes; we return to that at the end.
What made migration inevitable: Company paved path
When the migration kicked off, sbt wasn't being weighed against Gradle in a vacuum — it was being weighed against the rest of Box's engineering stack, which had standardized on Gradle years earlier. New Java services were generated from Gradle templates, shared libraries were published through a Gradle-based release flow, and the platform team's CI/CD paved path — builds, artifact promotion, security scanning, coverage gates, secrets management — was implemented as Jenkins steps that called ./gradlew.
Aside from build and deploy, other important things like dependency version governance and third-party vulnerability scanning were shared Gradle mechanisms and plugins, so any repo already on Gradle inherited them instead of reinventing them.The Scala monorepo was the odd one out. Paved path alone would have justified the move, even if sbt had been fast — it was the only way for the codebase to plug into the same compliance, security, and deployment story every other team at the company already got for free.
For a company whose business rests on keeping customer content secure, being the one codebase outside that shared security and compliance tooling wasn't a comfortable place to sit.
What made migration urgent: Slow development loop
What our stack at the time was actually costing us was captured in a ticket about local dev experience on a single-service build:
Roughly 5 minutes to pull the JDK and base image, another 10+ minutes for sbt to initialize, 10+ minutes to resolve dependencies, and another ~10 to compile and link — a clean local build of one service took 25–27 minutes.
In September 2015, we started to benchmark Gradle head-to-head against sbt on the same modules:

Gradle won the most important benchmarks, a dependency tree in 10.6s against 50s andan IntelliJ refresh in 18s against 145s for tested service.
However, warm sbt was ahead: 1s to print dependencies against 2.8s, and 34s to recompile after adding a library against Gradle's 35.7s.
So, Gradle never made the compiler itself faster - what it removed was the cost of everything around compilation, which was most of the wait.
Engineers learned to compensate. Internal wikis grew a small library of heuristics to improve sbt load times and create workarounds for slow IntelliJ project loads. They worked, but didn't fix the underlying problem: the code-test-feedback cycle stayed slow.
Dev experience was the most visible symptom; CI/CD got hit harder. From the team's internal Fixing-Infra deck:
Built on ClusterRunner, which doesn't fit Scala's build model
Repeated build on ClusterRunner nodes
Takes long time & many nodes for each build
ClusterRunner, the company's parallel test executor, assumed a build model sbt didn't have. So, each shard rebuilt the world, burning fleet capacity and giving Scala engineers a feedback loop measured in hours while their Java neighbors next door could complete their work in tens of minutes.
Flakiness compounded the cost: at one point a PR that did nothing but append blank lines to a few build files reliably failed CI.
The team's own framing of the problem, from the same deck, was generous:
we're just misusing sbt
SBT is a fine build tool for the projects it was designed for. At Box's scale, what we had built on top of it had quietly outgrown it.
Why Gradle works for Scala at scale
Remote cache
Gradle models a build as a graph of tasks with declared inputs and outputs. If a module's compileScala inputs haven't changed, the result is fetched rather than recomputed — including when another machine produced it. sbt 0.13 could reuse its own previous work on the same machine; it could not reuse anyone else's. (proof)
The infrastructure is modest: a single pod — 4 CPU, 4 GiB RAM, one 100 GiB disk — serves the whole monorepo, moving ~337 GiB/month (link).
Our conscious trade-off is that only CI pushes to the cache. Sharing an entry is only safe if the output depends on nothing but the declared inputs, and on a developer machine it can also depend on a locally published library, leftover generated sources, or whatever JDK happens to be current. CI builds from a clean checkout with the Scala and JDK versions pinned explicitly, so it is the only producer we trust.
The limit is that a cache only returns what someone already built — in practice, commits CI has built. For local development, its value falls off the more of the repository sits downstream of your change: an engineer working in a leaf service starts almost fully cached, while a change to a foundational library invalidates its entire downstream closure, and everything below it compiles from scratch.
This is why the cache pays off most reliably in CI, where every PR validation used to rebuild the world — the feedback loop measured in hours we described earlier.
Remote caching is the largest single reason a typical PR validation runs in about 12 minutes today, the range the rest of the company's CI has always been in. (link)
Partial build
Remote caching makes recomputing a module cheap. Partial builds make ignoring a module free — and in a 360-module repo, most days you touch a handful of modules, not the whole tree.
We use Gradle for partial builds on two levels:
org.gradle.configureondemand=trueevaluates only the requested project and its declared dependencies, and because the module set is decided by ordinary code in settings.gradle, we can narrow the build even earlier- A custom generator walks the dependency graph and emits a list of requested modules plus their transitive closure, including runs for nothing else
That narrowing pays off on the developer's desk too. The full build is hard to hold. Loading all modules into Metals — the open-source Scala language server — routinely exhausts memory on a normal laptop, so against the whole repo it just doesn't work.
Point it at the modules you're working on and the language server imports a build small enough to fit.
Recently released Metals v2 made working against a large codebase substantially faster - we have already seen that on this monorepo. Even so, few teams here need every module loaded at once, so pointing the language server at a Gradle partial build remains the practical default.
The net effect: build cost scales with what you changed (and downstream of your change considering the cache behaviour), not with how big the repo is - the property a monorepo at our scale really needs.
The internal guide the migration team wrote at the time led with the same thing engineers noticed first — "lightning project loading and dependency resolving," and an IntelliJ import that "loads in just seconds."
Same compiler, different orchestration
Neither of those levers touches the compiler. Gradle's Scala plugin drives Zinc — the same org.scala-sbt incremental compiler sbt itself uses — so the part that turns Scala into bytecode came across unchanged, file-level incremental compilation included. What we replaced was everything around it: what gets compiled, in what order, and whether it needs compiling at all.
What required extra investment
Gradle can compile Scala, but compiling Scala is only a small part of what sbt’s ecosystem provides. The migration meant rebuilding several Scala-specific conventions that sbt plugins had previously handled.
The cost of the migration itself
Rewriting build.sbt as build.gradle was the smallest part of the work. A service could not build on Gradle until every library it depended on could too, so the effective unit of migration was the whole transitive closure: converting one service often meant converting around twenty libraries first.
Because that closure could not be converted atomically, more than a hundred modules spent an extended period carrying both build definitions at once. Every dependency change, version bump, and compiler flag had to be applied twice and kept semantically identical. Dual-build was the price of migrating incrementally rather than in a single flag day, and it became the largest recurring cost of the transition.
That structure is why the migration needed a dedicated team rather than a shared migration guide. A small central group built the common Gradle infrastructure, converted the foundational libraries, and worked with each team as its part of the graph became ready.
The cost of continuous support
Recreating Scala-native build tooling
Scrooge and Thrift generation were the clearest example of something sbt handled automatically. With sbt, Scrooge provides build-tool integration as part of its native ecosystem. On Gradle, we had to create and maintain the integration ourselves: running code generation, registering generated sources, and making them visible to the compiler and IDE.
Also, BuildInfo generation, previously supplied by an sbt plugin, became a custom Gradle task.
Compiler plugins required similar plumbing. Instead of declaring a plugin through a Scala-specific build abstraction, we had to manage its dependencies and scalac configuration explicitly.
Smaller gaps accumulated too. We added custom tasks for Specs2 discovery, functional-test source sets, the Scala REPL, aggregate reports, retries, and generated build metadata.
None of these pieces was particularly complex on its own, but together they formed a Scala-specific build layer that we had to own and maintain.
Cross-building and cross-publishing
Scala libraries compiled for different binary versions of the compiler are generally not compatible. The ecosystem therefore publishes a separate artifact for each version—for example, library_2.12 and library_2.13.
SBT models the Scala binary version explicitly. %% selects the matching dependency suffix, crossScalaVersions declares the supported versions, and +publish builds each variant. Its resolver also detects when incompatible Scala versions enter the same dependency graph.
Gradle has no equivalent concept: _2.12 is simply part of an artifact name, unrelated to _2.13. We therefore add the suffix explicitly and pass the Scala version into the build as a global parameter.
When a Java library needed a Scala version (link)
The difference became particularly visible when our usual Scala → Scala → Java layering was inverted. A Scala library depended on a Java client, which itself depended on a Scala metrics library. Although the Java client contained no Scala code, its dependency closure did, so we had to publish separate _2.10 and _2.12 variants of the Java artifact.
Choosing the matching Scala dependency in Gradle was easy. Changing the artifact identity consistently across the build and release pipeline was not. We tried version-specific subprojects, composite builds, symbolic links, and a Scala multiversion plugin. Some failed under Gradle’s project model; others built successfully but duplicated configuration or did not fit our signing and publishing pipeline. For that library, the least disruptive solution at the time was to publish the variants manually.
SBT would not have removed the underlying incompatibility or the need for two artifacts. It would, however, have provided a native axis connecting the compiler version, dependency suffix, and published artifact name. With Gradle, we had to coordinate those pieces ourselves.
At the monorepo level, CI invokes Gradle separately for each supported Scala version, while wrapper scripts encode the supported Scala and JDK combinations. This works, but the build matrix lives partly outside Gradle and must be maintained by us. The ongoing move to Scala 2.13 has made that cost visible again: compatibility is tracked module by module rather than obtained from a single cross-build declaration.
Adapting code coverage
Not every replacement was equivalent. The Gradle build uses JaCoCo for coverage rather than Scala-aware scoverage.
JaCoCo operates on JVM bytecode, where case classes, closures, and other Scala constructs generate synthetic methods. Some services consequently maintain explicit exclusion lists to keep coverage gates meaningful.
Coverage is the sharpest example, but the pattern behind all of these is the same trade-off. Gradle gave us the shared company platform and the task model needed to operate the monorepo at scale, but it did not give us sbt's Scala-native ecosystem. We exchanged standard Scala tooling for infrastructure that fit Box as a whole — and accepted ownership of the missing layer.
Alternative approaches
The landscape has changed since we made this choice, and we follow it: today's Bazel, Mill, and sbt solve by design some of what we built by hand. SBT 2.0 ships with a Bazel-compatible local and remote cache, with compile and test cacheable out of the box. (Congratulations to the community on that release!)
But Gradle gives us the two things we needed most — a workable, steadily improving developer experience and a single paved path shared with every other repo at Box — and having spent years on one build migration, we have a precise sense of what another one would cost.
Lessons learned: what we'd tell someone choosing today
Ask "where does my repo need to plug in?" before "which build tool is best for Scala?"
One of our main drivers was the paved path. Security scanning, dependency governance, CI/CD, compliance: all of it already existed as Gradle mechanisms, and any repo on Gradle inherited them instead of reinventing them.
If your company's paved path runs on sbt, or you don't have one at all, your calculation is different. Start from where you need to integrate, not from the tool's language-native ergonomics.
This needs a dedicated team, not a migration guide
A service couldn't build on Gradle until its entire transitive closure could too, so the real unit of migration was the whole dependency graph — converting one service often meant converting ~20 libraries first. That closure can't be converted atomically, so 100+ modules carried two build definitions at once, and every version bump and compiler flag had to be applied twice and kept identical.
You don't get through that with a shared wiki page. It takes a central group that builds the common infrastructure, converts the foundational libraries, and works with each team as its part of the graph becomes ready.
You're trading a Scala-shaped toolchain for a JVM-shaped one (and you'll own the difference forever)
Gradle can compile Scala, but compiling Scala is only a small part of what sbt's ecosystem gave us. Scrooge/Thrift generation, BuildInfo, cross-version publishing, etc - we had to recreate and then maintain all of it.
Our general-purpose tool reasons in JVM terms, and every friction point is exactly where Scala's semantics show through.
Not every replacement is equivalent - verify the semantics, not just that it builds
The clearest example is coverage differences between Scala-aware scoverage and JaCoCo. "It builds and reports a number" isn't the same as "the number still means what you think."
When you swap a tool, the danger isn't the build breaking — it's the build succeeding while quietly measuring the wrong thing.
And the honest caveat: none of this means "Gradle beats sbt."
Gradle was a decision Box made, but coping it without copying the context means inheriting the bill without the benefit.

