A modularized project always looks good on a diagram: boxes, arrows, an owner for each box. What the diagram leaves out is that every box arrives with a build file, a resources folder, a test target and a boundary somebody has to maintain. In the codebases we audit, a team of six struggling to keep fifty modules alive is at least as common a sight as the app that is "still one module".
We have nothing against modules. In large teams and large codebases they work, and we recommend them. But the decision is a cost calculation, and hardly anyone writes down the expense side. Even Android's own modularization guide says so plainly: every module brings overhead in build complexity and boilerplate, and if the codebase is not expected to grow past a certain threshold, the scalability and build time gains will not apply.
Our running example is a made-up travel booking app called Rota: three iOS developers, three Android developers, twelve groups of screens, a product two years old.
The bill nobody shows you
The Rota team comes back from a conference and decides on "one module per feature". Six months later the iOS side looks like this:
Packages/
Core/ ← everything depends on this
CoreUI/
Networking/
SearchInterface/ SearchFeature/ SearchFeatureTests/
BookingInterface/ BookingFeature/ BookingFeatureTests/
PaymentInterface/ PaymentFeature/ PaymentFeatureTests/
ProfileInterface/ ProfileFeature/ ProfileFeatureTests/
... (8 more features)
SearchDemoApp/ BookingDemoApp/ ... ← preview and test hosts
Twelve features make thirty-six targets: an implementation, an interface and a test module each. Add the infrastructure modules and the demo apps and the count passes fifty. The amount of code has not changed, only the number of containers carrying it. Here is what each container costs.
Build configuration per module. Every module has a deployment target, a Swift language mode, compiler flags, lint settings and a dependency list. On Android that is a build.gradle.kts, a namespace, compileSdk, ProGuard rules and a plugin list. Convention plugins or shared xcconfig files reduce the repetition, and in exchange you now own a build logic codebase that needs maintaining. A job like moving to the Swift 6 language mode becomes fifty separate decisions across fifty targets.
Interface modules double the count. To stop features depending on each other directly, each feature gets an interface module. The technique is sound, but once twelve features have become twenty-four modules, every new screen starts with a debate about which module the protocol goes in and which one gets the model.
Cross-module navigation and DI. Where a single module let you write PaymentViewController(order:) and move on, there is now a protocol, a registration and a resolution step:
// :feature:payment:api
interface PaymentEntry {
fun intent(context: Context, orderId: String): Intent
}
// :feature:payment:impl
internal class PaymentEntryImpl @Inject constructor() : PaymentEntry {
override fun intent(context: Context, orderId: String): Intent =
PaymentActivity.newIntent(context, orderId)
}
@Module
@InstallIn(SingletonComponent::class)
internal abstract class PaymentEntryModule {
@Binds abstract fun bind(impl: PaymentEntryImpl): PaymentEntry
}
// :feature:booking:impl, the side that wants to open the payment screen
@HiltViewModel
class BookingSummaryViewModel @Inject constructor(
private val paymentEntry: PaymentEntry,
) : ViewModel()
Nothing is wrong with this code. The trouble is that going from one screen to another now takes three files and two modules, and you pay that price again at every transition. A "this screen does not exist" mistake that the compiler used to catch also moves to the moment the DI graph is assembled, or to runtime.
Resources and localization. On iOS, every resource lookup through Bundle.main breaks when the code moves into a Swift package target and has to become Bundle.module. Once strings are spread across modules, your translation process works with twelve files where it had one. On Android, non-transitive R classes have been the default since AGP 8.0: each module's R class contains only its own resources. That is good for the build, but a string shared by three features has to live somewhere, and that somewhere is usually a :core:ui module everything depends on.
Previews and test hosts. Running a feature module in isolation needs a demo app that feeds it fake dependencies. Tests that need the keychain or an entitlement need a host app. Each of these is a target whose upkeep gets forgotten until somebody finds it broken one morning.
Project loading and indexing. The work the IDE does on open and on every branch switch grows with the number of targets. On Android there is a documented reason: according to the Gradle documentation, IDE imports and syncs do not yet use the configuration cache, so you cannot cache your way out of the effect module count has on sync time. On iOS we suggest measuring it in your own project: for one week, note how long indexing takes to settle after a branch switch.
Onboarding load. A new developer has to answer "where does this class go" from fifty options. When they pick wrong, the compiler greets them with a circular dependency error.
The expensive one: a wrong boundary frozen in place. Inside one module, moving a class to another folder is a drag and drop. Across modules, the same move is a public API change, a dependency graph change and often an interface module change. So a badly drawn boundary does not get fixed; people route around it. At Rota, the "cart" concept went into Booking first, then Payment needed it too, and it ended up in Core. That is how Core grows.
A module boundary is a decision to make moving code expensive. If the boundary is right, that expense protects you. If it is wrong, it locks you in.
Where to draw the line is the subject of our article on domain boundaries. The claim here is narrower: if you are not sure where the boundary runs, do not pour it in concrete.
iOS: how you link decides launch time and binary size
On iOS there is a second decision besides the module count: how each module is linked into the app. Apple's documentation sums up the trade. A static library is copied into the binary at build time, which can increase build time and app size. With a dynamic library, symbols are resolved by dyld when the app starts, which can increase launch time. Apple writes that each additional framework the app loads adds to launch time, and that although dyld caches part of this work in a launch closure, what remains still depends on the number and sizes of the libraries loaded.
Embed fifty modules as dynamic frameworks and your users pay for it. Link them all statically and launch recovers, but two new problems appear: a large link step after every change in debug builds, and the same static module linked into both the app and an extension (or into two separate dynamic frameworks), which ships the code twice. The second one is sneaky. The binary grows, and the singleton you believed was single now exists in two copies.
In Swift Package Manager, if you leave a library product's type unspecified, SwiftPM chooses between static and dynamic linking, and Apple recommends leaving it that way. Which means most teams never consciously made this decision. Open the build log once and look at how each module is actually linked.
What mergeable libraries change
Mergeable libraries, introduced with Xcode 15, remove most of this trade-off. In the documentation's words: launch times similar to static linking in release builds, without losing dynamically linked build times in debug builds. In debug, Xcode treats mergeable dependencies like normal dynamic libraries and reexports them. In release, it merges the libraries into the target binary. The configuration is short:
// App.xcconfig: on the app target
MERGED_BINARY_TYPE = automatic
// If you want to manage it by hand:
// on the app target MERGED_BINARY_TYPE = manual
// on each framework MERGEABLE_LIBRARY = YES
automatic merges direct dependencies only: libraries listed in the target's Link Binary With Libraries phase that are also products of another target in the same project. Indirect dependencies need manual configuration. Read it together with Apple's own notes: the release binary is slightly larger, and automatically merging dependencies shared between an app and an app extension can inflate the extension, which is the case where manual configuration is suggested.
Mergeable libraries are a solution for framework and dylib targets. If your modules are Swift package targets that SwiftPM already links statically, nothing changes for you. Our conclusion: on iOS you cannot answer "how many modules" separately from "how are they linked", and you cannot decide either without measuring a release build on a real device. The tools Apple points to are the App Launch template in Instruments, the Launch Time pane in the Xcode Organizer, and MetricKit.
Android: every module is charged to configuration time
Gradle runs a build in three phases, and the configuration phase runs no matter which task you asked for. Gradle's performance guide says it directly: any expensive operation in this phase slows down every build, including simple commands like gradle help. More modules means more projects to configure. In an eighty-module project, changing one line and running the app may take a second of compilation, but eighty build.gradle.kts files are evaluated first.
What configuration cache and isolated projects change
The configuration cache stores the result of the configuration phase and skips the phase entirely on later builds when the inputs have not changed. It has been documented as the preferred mode of execution since Gradle 9.0, and according to the documentation it will be on by default in Gradle 10. Today you still have to turn it on:
# gradle.properties
org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.caching=true
Do not overestimate how much of the bill this covers. The cache is invalidated when build scripts, gradle.properties, the version catalog or an environment variable read at configuration time changes. In a team that switches branches and bumps dependencies, the first build after each of those pays for full configuration again. And as noted above, IDE sync does not use the cache.
The feature aimed at that remainder is isolated projects: it isolates projects from each other so that configuration can run in parallel and be cached per project. It was promoted from experimental to incubating in Gradle 9.7 and is enabled with org.gradle.isolated-projects=true. The price is that a project can no longer reach into another project's mutable state: allprojects {} and subprojects {} blocks in the root build.gradle.kts have to move into convention plugins. Before tying your build to an incubating feature, try your plugins against it in your own project.
The conclusion points the same way as on iOS. The cost per module is falling, it has not reached zero, and it only falls if your build logic is clean. An eighty-module project that hands out settings through subprojects {} in the root file gets the full benefit of none of these features.
When "but builds get faster" is true
It is true in these cases. In an incremental build, if the change is in a leaf module and that module's public API did not change, only that module is recompiled. Modules that do not depend on each other compile in parallel, and the gain grows with your core count. With a remote build cache, unchanged modules are not compiled at all, on CI or on a developer's machine.
The cases where it is false get less airtime. In a clean build the total work does not shrink; it grows by the fixed cost of each module. In a deep graph parallelism does nothing for you, because if A waits for B, B for C and C for D, four modules compile one after another. The WWDC22 session on build parallelization explains this with the critical path: the longest dependency chain sets the lower bound on build time, and the module count has no say in it.
The most common failure, though, is the Core module:
Search ──┐
Booking ─┤
Payment ─┼──► Core (models, extensions, analytics, feature flags, Money, DateFormatter…)
Profile ─┤
... ─────┘
Touch one file in Core and every module that depends on it recompiles. If most of the team's daily changes touch Core, your fifty-module project behaves like a single module for incremental builds, and you pay the fixed cost of fifty modules on top. Apple's incremental build guide makes the same point with a monolithic utilities framework. On Android, every dependency declared with api where implementation would do spreads the effect one level further.
Measure before you decide
Most teams that complain about build time have never measured where the time goes. Modularization is an investment of weeks. Half a day of measurement is cheap next to it.
iOS
# Clean build with a timing summary per target and task type
xcodebuild -workspace Rota.xcworkspace -scheme Rota \
-configuration Debug \
-destination 'generic/platform=iOS Simulator' \
-showBuildTimingSummary clean build | tail -n 60
Inside Xcode the same report comes from Product > Perform Action > Build With Timing Summary and shows up under that build in the Report navigator. Then run three incremental scenarios separately: a body change in a leaf feature, a public signature change in the same feature, and a change in the module with the most dependents.
The build timeline (Xcode 14 and later) answers the real question. With the build log open, open the Assistant from the editor options and the timeline appears next to the log. The vertical axis shows how many tasks ran at once, colours show targets, and empty space shows work waiting on an unfinished task. If most of the timeline is one row tall, your problem is a lack of parallelism, and flattening the graph fixes that where adding modules will not. If one long block is a script phase, your problem may have nothing to do with modules at all. Apple's guide tells you to check this first: any Run Script phase without declared input and output files runs on every build.
Android
# See what configuration costs on its own
./gradlew help --profile
# The clean measurement recommended in the Android documentation
./gradlew clean
./gradlew --profile --offline --rerun-tasks assembleDebug
The --profile report is written as HTML under build/reports/profile/ and breaks out configuration, dependency resolution and task execution in separate tabs. Look at the per-project configuration times: if a noticeable share of the total sits there, adding modules will make things worse. A Build Scan produced with --scan shows the same data with a task timeline and cache hit rates.
A single measurement is noisy. For numbers you can compare, use gradle-profiler (brew install gradle-profiler). By default it runs six warm-up builds and ten measured builds:
# build.scenarios
clean_build {
tasks = [":app:assembleDebug"]
cleanup-tasks = ["clean"]
}
feature_body_change {
tasks = [":app:assembleDebug"]
apply-non-abi-change-to = "feature/search/src/main/kotlin/com/rota/search/SearchViewModel.kt"
}
core_abi_change {
tasks = [":app:assembleDebug"]
apply-abi-change-to = "core/common/src/main/kotlin/com/rota/core/Money.kt"
}
gradle-profiler --benchmark --project-dir . \
--scenario-file build.scenarios \
clean_build feature_body_change core_abi_change
# Results: profile-out/benchmark.html
The gap between the second and third scenario is the size of your Core problem. Take these three numbers with today's structure and write them down. After modularizing, do not say "it got faster" until you have rerun the same scenarios.
When we advise against modularizing
A small team. The most concrete benefit of module boundaries is ownership: who may change what, who reviews what. In a platform team of three, everybody changes everything, and a boundary produces only friction.
A young product. While the product is still finding its direction, features merge, split and get deleted. Boundaries drawn in year one have poor odds of being right in year two, and we have already covered what a wrong boundary costs.
Boundaries that are still moving. Count how many times in the last three months you had the "where does this class really belong" discussion. If it comes up often, your concepts have not settled, and it is too early to hand them to the compiler.
Build time may not be the bottleneck. If you measured and incremental builds turned out acceptable, or the slowness comes from a script phase, kapt or an unconfigured cache, modularization is the wrong medicine. The Android documentation, for example, states that kapt is significantly slower than KSP, and that migration is far cheaper than creating fifty modules.
The cheap middle ground: one module, strict rules
Most of what modules promise (readable boundaries, a narrow public surface, a dependency direction) can largely be had without creating a module. What you cannot get is compiler enforcement and build isolation. Buy those when the need shows up.
Android
Kotlin's packages and imports make this easy. Each feature lives in its own package, and everything it exposes sits in an api subpackage:
app/src/main/kotlin/com/rota/
feature/
search/ { api/ ui/ data/ }
booking/ { api/ ui/ data/ }
payment/ { api/ ui/ data/ }
core/
designsystem/ network/ model/
A unit test enforces the rule. It is twenty lines and runs on CI:
class FeatureBoundaryTest {
private val root = File("src/main/kotlin/com/rota/feature")
private val featureImport = Regex("""^import com\.rota\.feature\.(\w+)\.(\w+)""")
@Test
fun `features reach each other only through the api package`() {
val violations = root.walkTopDown()
.filter { it.extension == "kt" }
.flatMap { file ->
val owner = file.relativeTo(root).invariantSeparatorsPath.substringBefore('/')
file.readLines().mapNotNull { line ->
val (feature, subpackage) = featureImport.find(line)?.destructured
?: return@mapNotNull null
if (feature == owner || subpackage == "api") null
else "${file.name}: $line"
}
}
.toList()
assertTrue(violations.joinToString("\n"), violations.isEmpty())
}
}
Ways to build the same rule with ready-made tools are in our article on enforcing architecture rules on Android. The point here: this test gives you most of the information a module boundary would, and deleting it takes a minute. If you drew the boundary wrong, you move the package and update the test.
iOS
Swift has no namespace below the module, which makes this harder, and we will not pretend otherwise: every internal type in the module is visible everywhere, and with no imports there is no import rule to write. The tools you have are generous use of private and fileprivate, a single entry type per feature, and a naming convention wired into lint:
// Features/Payment/PaymentEntry.swift: the only face the feature shows the outside
@MainActor
enum PaymentEntry {
static func makeCheckout(orderID: Order.ID, onFinish: @escaping (PaymentResult) -> Void) -> UIViewController {
let viewModel = PaymentCheckoutViewModel(orderID: orderID, onFinish: onFinish)
return PaymentCheckoutViewController(viewModel: viewModel)
}
}
enum PaymentResult { case paid, cancelled }
# .swiftlint.yml
custom_rules:
payment_internals:
name: "Payment internals"
included: "Rota/Features/.*\\.swift"
excluded: "Rota/Features/Payment/.*\\.swift"
regex: "\\bPayment(?!Entry\\b|Result\\b)[A-Z]\\w*"
message: "Reach Payment only through PaymentEntry."
severity: error
It is a crude rule. It leans on naming discipline and can raise false alarms in comments. We accept that the middle ground is weaker on iOS than on Android, which is why we suggest extracting the first module somewhat earlier on iOS. Sturdier options are covered in our article on enforcing architecture rules on iOS.
Signals that it is time, and the order we extract in
When several of these signals show up together, it is time to create modules. Your measurements show that most of the incremental build goes into recompiling code that did not change. A second target is going to use the same code: a widget, an App Clip, a share extension, a Wear OS app. A second team with its own roadmap has joined the codebase and merge conflicts have turned into an ownership problem. Your lint rule has protected the same boundary for months and nobody argues about it. The last one is the most reliable signal, because the boundary has proven itself.
The order we extract in starts with the code that is most stable and carries the least domain knowledge:
1. Design system (colours, typography, components; knows no domain, rarely breaks)
2. Infrastructure (network, persistence, analytics client; behind a narrow API)
3. Stable domain core (models and rules whose signatures have not changed in months)
4. The clearest feature (the one with its own team, or needed by a second target)
5. The rest (only as signals arrive, one at a time)
The first three steps prevent the Core problem from the start: where there would have been one module everything depends on, you get three small ones that change for different reasons. In step four, create an interface module only where two features really do call each other. The app target can wire most features together, and then no interface module is needed. After every step, rerun the same measurement scenarios. If a number got worse, stop and find out why.
A cost and benefit worksheet for your own project
Fill in the table below as a team. Do not write estimates in the third column; write what you measured or counted.
| Question | How to measure | Your value | Does it push towards modules |
|---|---|---|---|
| Incremental build time after a body change in a leaf feature | Timing summary, gradle-profiler scenario | Yes, if it breaks the developer's flow | |
| How much of that time recompiles unchanged code | Build timeline, task tab of --profile |
Yes, if most of it | |
| Configuration or project loading time | ./gradlew help --profile, Xcode open and indexing time |
No if already high; fix that first | |
| Launch time in a release build | Instruments App Launch, Organizer | No, unless you have a linking plan | |
| Developers per platform | Count | No, below five or six | |
| Teams with an independent roadmap | Count | Yes, at two or more | |
| A second target that will share code | Check the roadmap | Yes, for that code only | |
| Classes moved between features in the last three months | git log --diff-filter=R --stat |
No if many; boundaries have not settled | |
| A boundary guarded by lint and unchanged for months | History of your rule file | Yes; start at that boundary | |
| Person who will own the build logic | Write a name | No, if there is no name |
Our verdict works like this: with fewer than three "yes" rows, do not create modules; apply the middle ground and fill the table in again in six months. With three or more, extract only the code the "yes" rows point at. No row in the table produces "one module per feature". That decision always comes from outside the table.
Checklist
- A clean build and three incremental scenarios (leaf body, leaf signature, most depended-on module) are measured and recorded.
- The build timeline or
--profilereport has been checked for where the time goes: script phases, configuration, annotation processing, linking. - iOS: the build log confirms whether each module is linked statically or dynamically, and release launch time was measured on a real device.
- iOS: if there are framework targets,
MERGED_BINARY_TYPEhas been evaluated, and dependencies shared with extensions were checked separately. - Android: configuration cache is on, no
allprojects {}orsubprojects {}remains in the root file, shared settings live in convention plugins. - If a
CoreorCommonmodule that everything depends on exists, splitting it by reason to change is planned. - Folder and package boundaries inside the single module are written down and enforced on CI by lint or a test.
- The first module to extract is stable code behind a boundary that has not moved in months.
- An interface module is created only where two features really call each other.
- The same measurements are retaken after every extraction, and work stops if a number gets worse.
- The build logic has an owner.
Need support on this?
Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.
Sources
- Apple · Configuring your project to use mergeable libraries
- Apple · Reducing your app’s launch time
- Apple · Improving the speed of incremental builds
- Apple · WWDC22, Demystify parallelization in Xcode builds
- Android · Guide to Android app modularization
- Android · Profile your build
- Android · Optimize your build speed
- Gradle · Configuration cache
- Gradle · Isolated projects
- Gradle · gradle-profiler
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.