DEV Community

Cover image for Architecture as a Cost Lever: Cutting Build Times by Splitting a KMP Monolith
The AX code
The AX code

Posted on

Architecture as a Cost Lever: Cutting Build Times by Splitting a KMP Monolith

Build time is a tax you pay twice: once on every engineer's attention each time they wait, and again on the CI bill every time a pipeline rebuilds the world. As the a Kotlin Multiplatform app grows — Android, iOS, desktop, and web builds; Camera implementations; a Bluetooth sync stack — a clean build turns into a coffee break, and CI minutes piled up on every push. Fixing this requires better architecture, not a better machine.

This is how splitting a monolith into modular KMP libraries with a framework-free core cut build time from a recurring cost into a rounding error — and why that's a cost decision as much as a cleanliness one.

The monolith tax

A single large module has three properties that quietly wreck build performance:

  1. Any change rebuilds (nearly) everything. Touch one file, recompile a huge compilation unit. Kotlin/Native and iOS compilation in particular scale badly with module size, so the worst case is the whole app.
  2. No parallelism. Gradle can run independent modules in parallel; one module is, by definition, a single lane.
  3. Cache misses everywhere. When unrelated code lives together, a trivial edit invalidates the build cache for things that didn't actually change — including in CI, which then rebuilds and re-tests the world on every PR.

Multiply that by a team and a busy CI queue, and you're paying real money and real flow-state for work the computer didn't need to redo.

The refactor: a core + capabilities

I split the app along its seams into independently versioned Kotlin Multiplatform libraries:

CoreLib        ← framework-free domain kernel (ports, use cases). Depends on nothing.
  ├── BluetoothLib    (BLE + encrypted sync)
  ├── CameraLib       (capture, synchronized triggers)
  ├── LocationLib / -NotificationLib / -HapticLib / -BackgroundJobLib
  ├── StorageLib / -PermissionsLib / -UILib
  └── App  ← composes the libraries
Enter fullscreen mode Exit fullscreen mode

Two principles did the heavy lifting:

  • A framework-free core (hexagonal). CoreLib holds domain logic and port interfaces only — no Android, no platform SDKs. Everything else depends inward on it; it depends on nothing. That keeps the dependency graph a shallow tree instead of a tangle, so a change in one capability library can't cascade rebuilds across unrelated ones. I enforce the boundaries with ArchUnit, so they don't erode over time.
  • One responsibility per module. Bluetooth doesn't know about cameras; the UI library doesn't know about persistence. Small, focused modules are small, focused compilation units.

The result is a graph where most edits touch one leaf and its direct consumers — not the universe.

Why this is faster, concretely

  • Only what changed recompiles. Edit the Bluetooth library and Gradle recompiles Bluetooth + the app, while Camera, Location, UI, and the core are pulled from cache untouched.
  • Modules build in parallel. Independent libraries compile on separate workers instead of single-file.
  • Cache hits across builds and machines. Stable libraries' outputs are reused locally and via a remote build cache — so CI frequently doesn't compile unchanged modules at all, it downloads their cached outputs.
  • Smaller worst case. The largest thing you can be forced to rebuild is now one library, not the whole app — which matters most for the slow native/iOS targets.

Composite builds: kill the publish-to-test loop

Each library publishes as a versioned package, but during development you don't want to publish → bump version → consume just to test a one-line change against the app. Gradle composite builds let the app include a library's source directly, as if it were a local module, while keeping it a real published artifact in CI. You get the iteration speed of a monolith with the build isolation of separate modules — this is where most of the day-to-day time savings came from (roughly [~40%] lower local-iteration + CI/CD overhead in my case; measure your own with build --scan).

The cost translation

Faster builds aren't just nice — they're cheaper, on two budgets:

  • The CI / cloud bill. On GitHub Actions, Cloud Build, or similar, pipeline minutes are billed minutes. Rebuilding and re-testing only changed modules cuts minutes per PR, and a remote cache means unchanged libraries cost ~zero. Over a team and a month, that's a line item.
  • The bigger budget: engineer-hours. A developer who waits less per iteration, dozens of times a day, is the real saving — engineer time dwarfs CI dollars. Shorter feedback loops also keep people in flow, which is its own multiplier.

And there's a reuse dividend: a published BluetoothLib or LocationLib drops into the next app without recompiling or re-solving the problem.

The honest trade-offs

Modularization isn't free, and over-doing it backfires:

  • Boundaries cost up-front design. You have to find the real seams; bad ones create churn instead of removing it.
  • Too many tiny modules add overhead. Each module has configuration and graph-resolution cost; past a point you're paying more in Gradle bookkeeping than you save. Granularity is the skill.
  • Versioning + publishing discipline. Independent libraries need real release hygiene (semantic versioning, a package registry), or you trade build pain for dependency pain.
  • Composite builds have a learning curve and some sharp edges with native targets.

The win comes from splitting along capabilities with a framework-free core — not from splitting as finely as possible.

Top comments (0)