Skip to content
All essays

Mobile architecture

When to modularize a mobile app (and when not to)

Modularization is usually sold as the cure for a slow build. A module is a rule the team pays for every week, in build configuration, review, navigation and onboarding. When a boundary earns that cost, when it does not, and how to tell you have gone too far, for iOS and Android.

10 min read

Most modularization proposals I see start from the same sentence: the app got big, so we should split it. Then comes a diagram with twenty boxes, a template for each feature, and a promise that builds will get faster. The diagram is rarely wrong. The question nobody answered is which change each box is meant to contain.

I have run 500+ technical interviews from the hiring side over 15 years in mobile, and architecture rounds and design reviews tend to go the same way on this topic. The answer that lands is not a module count. It is a reason per boundary, and a way to know if the boundary stopped paying. A module is a rule the team pays for every week, not a folder.

This article uses the cost model from my book Controlled Change, which puts it in one line: "Modularization is not the act of creating more build targets. It is the act of limiting which changes, concepts, and teams must move together." Everything below follows from that sentence.

  • The decision: should this codebase, or this part of it, become a separate module.
  • The tools: what a boundary costs, a four-question test, the team seam, and two measurements.
  • The exit: the signs you have split too far, and how to merge back without losing the rules.

What a module costs before it saves anything

Every boundary sends a bill. My book lists the line items for any abstraction, and a module carries nearly all of them: a new concept and vocabulary, navigation between files and modules, mapping between representations, build graph edges, indirection while debugging, test doubles, migration and compatibility work, versioning and ownership, and a narrower group of engineers who can change it with confidence.

One module is cheap. The cost compounds when a template repeats it. In the book's words, the organization then "pays in code review, build time, search, onboarding, and mechanical migrations." None of that shows up in the proposal, because the proposal is drawn on the day the boundary costs nothing.

The absence of a boundary costs too, and this is why "never modularize" is not the answer either. A volatile payment SDK used directly across the app is expensive to replace. A single app target speeds up early work and later forces teams to queue behind each other. The decision is which cost to pay, and when.

The four-question boundary test

Before drawing a module, ask four questions about the two sides of the proposed line. They come from chapter 4 of Controlled Change, and they work for a Swift package, a Gradle module or a plain folder.

QuestionWhat you askStrong yesWeak yes
VolatilityDo the two sides change for different reasons?A pricing rule and a JSON decoderA date string and the label that shows it
ConsequenceWhat happens if a change leaks across?Payment, identity, local migrations, remote commandsA decorative card
MultiplicityAre there real other implementations or consumers?iOS and Android, widgets, background work, tests"We might replace it someday"
CoordinationWill different teams or release clocks own the sides?Two teams, two roadmapsOne team that sits together

No to volatility: keep the code concrete. Yes to volatility but low consequence: leave a seam, a single place where a boundary could go later, and move on. Yes to both, plus independent owners or consumers: draw an explicit boundary. Yes to both without them: a local boundary, without platform machinery.

The order matters. The book's progression is concrete first, seam second, abstraction third, and the last step is the one teams skip: collapse the boundary when the pressure disappears.

The team seam is the strongest reason

Of the four questions, coordination is the one that most often justifies a module on its own. My book is direct about it: "An organizational seam can justify a technical contract even when the code is currently simple."

If two teams own the two sides, every change across an implicit boundary is a negotiation. A module turns that negotiation into a contract with an owner. The code inside may be trivial. What the module buys is that one team can change its side without asking the other, and the compiler tells you when that promise is broken.

The reverse also holds, and it is the part most proposals miss. If one team owns both sides and the code changes for the same reasons, the module buys no autonomy. It only adds an import statement and a manifest to every change. That is a cost with no matching benefit, whatever the diagram looks like.

When not to modularize yet

A prototype, or a small product with one team, usually gets more from feature folders, visibility conventions and tests than from physical modules. The book's rule for this stage is one line: "Create a build boundary when it enforces a decision that coordination alone no longer protects."

Good reasons to wait: the product boundaries are still moving, so any line you draw now will be in the wrong place next quarter; builds are acceptable; nobody can name an owner for the new module. A well-structured single app module with directional rules, enforced by folders and access control, can beat a fragmented graph.

And weak reasons to start: the folder has many files, a conference talk showed a clean graph, or the build is slow and nobody has measured why. A cohesive feature with thirty files can be easier to change than six small modules wired together with protocols.

Split by feature and capability, not by layer

When a split is justified, the most common mistake is to cut along technical layers: Presentation, Domain, Data, Common. Every feature change then crosses every layer, so every change touches every module. A global Domain module becomes something every feature imports, and an unrelated edit to it rebuilds most of the app.

The healthier shape has two kinds of module. Feature modules own a user-visible journey, with whatever presentation, state, rules and data mapping that journey needs. Capability modules own something many features use and few change: networking, storage, observability, the design system. Features depend on capabilities. Capabilities never depend on features. Features do not import each other; the app shell composes them.

A module named Common, Core, Shared or Utils is the warning sign. It attracts everything because importing it is easy. The book's verdict on it: "A Core module with massive fan-in and broad APIs is not a foundation. It is a congestion point."

iOS modular architecture: a Swift package is a rule only if CI checks it

With Swift Package Manager the boundary is the manifest. This one declares two features that depend on capabilities and not on each other:

Swift
// swift-tools-version: 5.9
import PackageDescription

let package = Package(
    name: "Features",
    platforms: [.iOS(.v16), .macOS(.v13)],
    products: [
        .library(name: "BookingFeature", targets: ["BookingFeature"]),
        .library(name: "SearchFeature", targets: ["SearchFeature"]),
    ],
    targets: [
        // platform capabilities: no feature may appear in their dependencies
        .target(name: "PlatformNetworking"),
        .target(name: "DesignSystem"),
        // features depend on capabilities, never on each other
        .target(name: "BookingFeature", dependencies: ["PlatformNetworking", "DesignSystem"]),
        .target(name: "SearchFeature", dependencies: ["PlatformNetworking", "DesignSystem"]),
        .testTarget(name: "BookingFeatureTests", dependencies: ["BookingFeature"]),
    ]
)

Here is the trap. Add import BookingFeature to a file in SearchFeature without declaring the dependency. In a test package built with Swift 6.3, a clean swift build failed with "no such module", but an incremental build passed, because BookingFeature had already been built. The rule held or not depending on build order. Running swift build --explicit-target-dependency-import-check error failed the build in both cases. Put that check, or your build system's equivalent, in CI, or the manifest is a suggestion.

Inside a package, the package access modifier (Swift 5.9 and later) lets targets share declarations without making them public to the app. Useful, but access control alone is not the architecture: the graph rules are.

Android modularization strategy: Gradle modules, api and implementation

In Gradle the same rule is the dependency block. A module only sees the modules on its compile classpath. Declare a dependency with implementation and it stays out of your consumers' compile classpath; declare it with api and every consumer can see it too. Default to implementation, and treat each api as a promise you will keep compatible.

Kotlin's internal visibility is scoped to the module, which gives a feature module a real inside and outside. The book names the Android-specific risk on the build side: annotation processing or non-incremental plugins can erase the gains of splitting. Measure before you announce faster builds.

On both platforms, the module graph is a data structure you can export, diff and check. Treat a new edge between features the way you treat a failing test.

Measure the split by how work moves, not by module count

A module count is the easiest number to report and the least useful. Controlled Change measures whether the split changed how work moves:

  • Change coupling: which modules change together in the same commits. If a new booking field still touches four modules, the boundary is in the wrong place.
  • Share of builds that touch the whole app. A feature split that still rebuilds everything has not bought build isolation.
  • Edit-to-test time for a representative change, on a typical developer machine, not the fastest one.
  • Public API growth and cross-feature imports, including the exceptions you approved.

The book's line for a graph that grows while these numbers do not move: "A module graph that grows while change coupling remains high is organizational theater." Exceptions are allowed, for example during a vendor migration, but each one gets an owner and an expiry date, and CI rejects it after that date.

Signs you should merge modules back

Over-modularization looks like a distributed monolith on the device: a small feature needs edits in ten modules, shared models change weekly and rebuild everything, tests need the full app graph, and teams route around the rules with global events or a shared database. "The build graph may be acyclic while runtime knowledge forms a knot."

Ask five questions about each module: does it have a coherent owner, a stable external contract, a meaningful change radius, can it be built or tested on its own, and would merging it with a neighbor reduce coordination without weakening a boundary? If the fifth answer is yes, merge.

The book's fictional reference system, Atlas, shows the pattern in its casebook. A uniform template split every capability into api, impl, ui and data modules and grew the app past a hundred modules within two quarters. Change history showed most product changes touched three or four modules of the same capability together. Atlas merged each capability back into one or two modules, kept one small public API module per capability, and dissolved the shared model modules into their owners. "The dependency rules survived. The granularity did not."

Make the merge a routine: once a quarter, list modules with one consumer, the same owner as a neighbor, synchronized releases and no build, security or migration value. Merging them is not a failure of the plan. It is the plan working.

A one-page record for one boundary

Before you create the module, write five lines. They fit in a pull request description or an architecture proposal review:

  • The change this boundary contains, in one sentence.
  • The answers to volatility, consequence, multiplicity and coordination.
  • The owner of the module and of its public contract.
  • The measurement that should improve: change coupling, edit-to-test time, or full-app builds.
  • The collapse condition: what would make you merge it back.

If you cannot fill in the first line, you are not ready to split. If you cannot fill in the last one, you are not ready to own the split. The same discipline applies to patterns inside the module, which is the subject of the MVVM, MVI and TCA answer, and to splitting a legacy app while it keeps shipping, covered in the architecture migration answer.

Questions engineers ask about modularizing mobile apps

When should you modularize a mobile app?

When a boundary would contain a change that currently spreads: two sides that change for different reasons, a leak that would be costly, real alternative consumers, or different teams and release clocks on each side. A large folder or a slow build on its own is weak evidence. One team on a young product is usually better served by feature folders, visibility rules and tests.

Does modularization make builds faster?

It can, by letting the build system skip modules that did not change. It can also make them slower: past a certain granularity, scheduling, module interface generation, linking and configuration eat the gains. Measure an edit to private code in a leaf feature and an edit to a widely used public type, before and after.

Should I split by layer or by feature?

By feature and capability. A global Domain or Common module becomes something every feature imports, so an unrelated change rebuilds the graph. Feature modules that own their own presentation, state and data mapping keep most changes inside one module and one team.

How do I know I have too many modules?

A small feature needs edits in many modules, the same modules always change together, tests need the whole app, and teams route around the rules with global events or type aliases. When merging two modules would reduce coordination without weakening a boundary, merge them.

Share this essay