skip to content

How would you decide when to model a piece of code as a separate subproject wired in via project(...) dependencies versus keeping it in an existing module?

level: principalimportance: nice to knowfreq 30%

answer

  1. split for reuse, enforced API, parallelism
  2. don't split churny / single-consumer code
  3. default implementation, api only for re-export
  4. convention plugins for consistency
  5. fitness functions enforce allowed edges

basics

~20 s

Split into a separate subproject when the code has a clear, reusable boundary, needs an enforced API surface, or improves parallel/incremental builds. Wire it with implementation(project(...)) by default, exposing only its real public API via api.

solid answer

~50 s

Splitting a module is a trade-off. Reasons **to split** into a `project(...)`-wired subproject: a stable reusable boundary (shared by multiple consumers), the need to **enforce** an API surface (so `implementation` hides internals), independent test/build lifecycles, and better **parallelism and incremental builds** (Gradle builds independent subprojects in parallel and recompiles fewer things). Reasons **not to split**: tiny or churny code, one consumer only, or a boundary that isn't real yet — premature splitting adds build files, cross-project config, and resolution overhead. My default policy: keep cross-project deps `implementation` to firewall coupling and shrink the recompilation graph; promote to `api` only types that are genuinely re-exported. I'd lean on metrics (build scans, recompilation blast radius) and architecture fitness functions to validate that splitting actually paid off, and use convention plugins so module wiring stays consistent across a large graph.

go deeper

for a junior

Not expected to make this call.

for a middle

Can list basic pros (reuse, faster builds) and the implementation-default rule.

for a senior

Weighs build performance, encapsulation, and overhead; explains how implementation/api wiring supports the boundary.

for a principal

Frames module granularity as governed architecture — convention plugins, fitness functions, build-scan metrics — and ties splitting decisions to measured outcomes across a large graph.

## The decision A subproject wired via `project(":x")` is a real architectural unit, not just a folder. Deciding to create one is about whether the **boundary is worth its cost**. ## Forces favouring a split 1. **Reuse** — multiple modules need the same code. A subproject with a clear API is the natural home, consumed via `implementation(project(":shared"))` / `api(project(":shared"))`. 2. **Encapsulation / API enforcement** — once code is its own module, `implementation` vs `api` lets you *enforce* what leaks. You can't accidentally reach into internals of another module the way you can within a single module. 3. **Build performance** — Gradle builds independent subprojects **in parallel** (`--parallel`/configuration on demand) and, with proper `implementation` boundaries, recompiles a smaller set when something changes. Splitting along true seams shrinks the recompilation blast radius. 4. **Independent lifecycle** — separate test suites, separate plugins/toolchains, or different release cadence. ## Forces against a split 1. **Overhead** — every subproject adds a build script, configuration time, and resolution edges. Many tiny modules can *slow* configuration. 2. **Premature boundaries** — if the seam isn't real, you'll churn the API and constantly move code across modules. 3. **Cognitive load** — more `project(...)` edges to reason about; risk of accidental `api` leakage creating hidden coupling. ## Wiring policy once you split - Default every cross-project edge to **`implementation(project(...))`**. - Promote to **`api(project(...))`** only when a module's types are part of the consumer's *own* public API (true re-export). - Watch for accidental leakage — a consumer compiling against a deep transitive type is a smell. ## Governing it at scale - **Convention plugins** (`buildSrc` or an included build) centralise how modules are wired so the policy is uniform. - **Build scans / dependency reports** quantify whether the split improved parallelism and incremental build times. - **Architecture fitness functions** (e.g. tests asserting module dependency rules) prevent forbidden `project(...)` edges from creeping in. ## Rule of thumb Split when the boundary is *real, reused, and worth enforcing*; otherwise keep it in place and refactor later. Let measured build behaviour, not aesthetics, drive module granularity.

  • How does splitting into subprojects improve build performance, concretely?
    Gradle builds independent subprojects in parallel and, with implementation boundaries, recompiles only the modules that can actually see a change. A good split shrinks both the parallelisable unit size and the recompilation blast radius; build scans quantify the gain.
  • How do you stop accidental cross-module coupling once you have many project(...) edges?
    Default edges to implementation, reserve api for true re-exports, centralise wiring in convention plugins, and add architecture tests (fitness functions) that fail the build if a forbidden project dependency or api leak appears.

saying these in an interview costs you the question

  • Advocating splitting everything into micro-modules without weighing configuration/maintenance cost.
  • Defaulting all cross-project edges to api, defeating the encapsulation that motivates splitting.
  • Deciding module granularity on aesthetics rather than reuse, enforced boundaries, or measured build impact.

context