Most modularization projects are won or lost on a decision made before the first module exists: where the boundaries go. The build system, dependency injection and navigation can all be fixed later. A boundary drawn in the wrong place reminds you of itself with every feature: a one-line change touches three modules, two teams collide in the same file, and the module you called "common" swallows half the app within a year.
We will use a hypothetical travel app throughout, call it Rota. Users search for flights and hotels, book, pay, and see the trips they bought. One target, six years of code, four teams. Management wants modules, and the shape drawn on the whiteboard in the first meeting is most likely a copy of the tab bar.
Teams usually cut in the wrong place
There are five cutting lines we see often in large codebases, and each of them looks reasonable at first glance.
The first is screens. One module per screen is easy because a screen is the most visible unit in the code. A screen is a unit of design, though. In Rota the "booking summary" screen appears both before payment and after purchase, with different rules in each case. Cut by screen and both sets of rules land in the same module.
The second is the tab bar. Tabs are the product team's navigation preference for this quarter. When the "Deals" tab is removed, DealsModule stays behind, along with the price alert code that lived inside it.
The third is backend endpoints. Three modules for /flights, /hotels and /orders import the backend's service boundaries into the client. The backend drew those lines for its own scaling and deployment needs. On mobile the user goes through a single booking flow, and that flow combines data from all three services.
The fourth is last year's org chart. Conway's law is real: code ends up resembling the communication structure of the teams that write it. The trouble is that module names outlive team names. A module called GrowthKit is still there years after the Growth team was dissolved, and nobody is quite sure what is in it.
The fifth is "whatever is big". Extracting the folder with the most lines feels like progress, but size alone tells you nothing. A big folder is usually three unrelated things piled in one place, and turning it into a module as is makes the pile permanent.
What these five signals share is that none of them accounts for how the code changes or who owns what.
The signals that hold up
Same word, different meaning
The most reliable signal is language. In Rota the word "price" shows up in three places. In search, a price is indicative: it is used to sort a list and may change a minute later. In booking, a price is a locked offer with an expiry time. In payment, a price is an amount to charge in a currency's minor units, and instalments and fees are computed from it. The three teams use the same word in meetings and understand each other, while in code a single price field tries to carry all three meanings:
// Models/Flight.swift: the "shared" model used everywhere in the app
struct Flight: Codable {
let id: String
var price: Decimal // indicative in search, locked offer in the basket, charged amount in payment
var currency: String
var seatsLeft: Int? // only set in search results
var fareLockExpiresAt: Date? // only set in a booking draft
var pnr: String? // only set after ticketing
var paymentStatus: String? // "3ds_pending", "captured", ...
var isCheckedIn: Bool? // only meaningful on the "My trips" screen
}
Every optional delegates "this field is set at that stage" to a comment. Look at which optionals are set together: the clusters that emerge are your domain candidates. Where the meaning of a word changes there is a boundary, whether you draw it or not. How a shared model gets into this state, and how to split it, is covered separately in shared models versus domain models.
What changes together stays together
The second signal is measurable and already sitting in your repository: which directories change in the same commits. The script below reads git log output and counts pairs of directories that changed together. It runs with the stock macOS awk, no extra tooling needed.
#!/usr/bin/env bash
# cochange.sh: lists pairs of directories that change in the same commits
# usage: ./cochange.sh [root dir] [directory depth] [period]
ROOT="${1:-.}"; DEPTH="${2:-3}"; SINCE="${3:-12 months ago}"
git log --since="$SINCE" --no-merges --name-only --pretty=format:'@%h' -- "$ROOT" |
awk -v depth="$DEPTH" -v maxdirs=8 -v minpair=5 '
function flush( a, b, n) {
n = 0
for (a in seen) n++
if (n <= maxdirs) # skip bulk refactors and formatting commits
for (a in seen) {
total[a]++
for (b in seen) if (a < b) pair[a SUBSEP b]++
}
split("", seen)
}
/^@/ { flush(); next } # a new commit starts
/^$/ { next }
{
n = split($0, p, "/")
m = (n - 1 < depth) ? n - 1 : depth
key = (m == 0) ? "." : p[1]
for (i = 2; i <= m; i++) key = key "/" p[i]
seen[key] = 1 # reduce the file path to its first "depth" directories
}
END {
flush()
for (k in pair) {
if (pair[k] < minpair) continue
split(k, d, SUBSEP)
lo = (total[d[1]] < total[d[2]]) ? total[d[1]] : total[d[2]]
printf "%4d %3d%% %s (%d) <-> %s (%d)\n",
pair[k], 100 * pair[k] / lo, d[1], total[d[1]], d[2], total[d[2]]
}
}' | sort -rn | head -25
You run it as ./cochange.sh App/Sources 4. Suppose the output for Rota looks like this (the numbers are illustrative, not taken from a real project):
41 87% App/Sources/Booking/Checkout (112) <-> App/Sources/Booking/Passengers (47)
38 31% App/Sources/Models (187) <-> App/Sources/Search/Results (121)
35 30% App/Sources/Booking/Checkout (112) <-> App/Sources/Models (187)
29 64% App/Sources/Booking/Checkout (112) <-> App/Sources/Payment/Card (45)
6 7% App/Sources/Search/Filters (83) <-> App/Sources/Search/Results (121)
The first column is the number of commits in which both directories changed. The numbers in parentheses are each directory's total commit count. The percentage is the shared commits divided by the total of the less frequently changed directory, so the first line reads "87% of the commits that touched Passengers also touched Checkout".
We look for three things. A high ratio inside one candidate domain is good news, those two directories already live together (first line). A high ratio across a boundary you were planning is bad news: either the boundary is in the wrong place or the contract between the two sides leaks internals (fourth line, Checkout and Payment/Card). A directory that is moderately coupled to everybody (Models) is a shared bag waiting to be taken apart. The last line is interesting as well: Filters and Results sit in the same folder and almost never change together, which makes them candidates for a later split.
Know the limits of the output. If the team squash-merges every PR, a commit equals a PR and the signal is clean. With many tiny commits the ratios come out lower than they really are. The measurement also sees only coupling inside one repository. For a more developed version of the same analysis look at Adam Tornhill's code-maat; for a first pass we find the twenty-five lines above sufficient.
Who owns the data, and how long it lives
For the third signal we ask two questions of every piece of data: who creates it and when does it die. Search results live for minutes, and nobody minds when they are evicted from the cache. A booking draft lives until the user pays or the fare lock expires. A purchased ticket lives for months and has to open on a plane with no connectivity. Card details should never live on the device at all. Put four kinds of data with lifetimes and storage rules this different into the same repository class or the same database table, and one's rules bleed into another's.
Each piece of data should have one writer. If two domains update the same record, either they are really one domain or the owner was chosen wrongly.
Rate of change, release and compliance constraints
The search screen changes every sprint: a new filter, a new sort order, A/B tests. Payment code changes a few times a year, and every change goes through a security review. If the two share a module, every search experiment recompiles the payment code and every payment review holds up the search team. Code that touches card data is audited separately because of compliance requirements such as PCI DSS, and you want that audit scope small. Keeping a third-party payment SDK behind a single module is valuable for the same reason. Code that changes at a different rate, or is subject to different rules, deserves to stand apart even when the language distinction is weak.
Bounded contexts on mobile: where you agree to translate
The bounded context idea accepts that a large system cannot be described by one consistent model, and limits the area in which each model is valid. Fowler explains it through the same word carrying different meanings in different parts of an organisation. In a mobile app the equivalent is quite concrete:
A boundary is the place where you agree to translate one model into another and to pay for maintaining an explicit contract.
The useful part of this definition is that it names the cost. Between Search's SearchOffer type and Booking's FareSelection type you will write a translation function. Most of the time that function is dull and copies three fields. That dull function is the price of letting the two sides change independently. If writing the translation at a candidate boundary seems pointless to you, because the model on both sides will always be identical, there is no boundary there and a folder is enough.
Mobile differs from the backend in one respect: boundaries live in the same process, in the same binary. There is no network call, no separate deployment, no versioned API. That makes a boundary cheap, and it also makes it invisible. Nothing but the compiler stops you, so it is up to you to make the contract visible through access control and module dependencies.
Signs a boundary is too big or too small
A boundary that is too big shows up in people. Two unrelated pieces of work produce merge conflicts in the same files. A PR needs approval from two separate teams. You cannot say what the module does in one sentence without an "and" in the middle. In the co-change output, the module's subfolders never change together.
A boundary that is too small shows up in commits. An ordinary feature touches three modules in one PR, and the three are always released together. The interface between them is chatty: a fifteen-method protocol exposes the other module's internal steps one by one. A data type passes through three modules unchanged and is redeclared with the same fields in each. In that situation, merging modules is the shortest way to fix a wrong cut.
When in doubt we prefer to err on the big side. Splitting a large module in two later is manageable work, because the couplings inside it are visible. Merging five small modules that were cut wrongly means dismantling five contracts first.
Contracts between domains: who is allowed to know whom
These are the dependency directions we aim for in Rota:
App (composition root) ──> all domains
Search ──> knows nobody
Payment ──> knows nobody
Account ──> knows nobody
Booking ──> Payment contract, Account contract
Trips ──> Booking contract (events only), Account contract
As a rule, the side that owns the flow knows the side that provides a service. Payment takes an amount and a reference and returns an outcome; it has never heard the words flight or hotel. Search has no need to know Booking either: when the user picks an offer, Search reports it outwards and the composition root does the translation.
We use one criterion to choose between a direct call and an event. If the caller needs the answer in order to continue, it is a direct call: Booking cannot issue a ticket without knowing the payment outcome. If the publisher does not care who is listening, it is an event: when a booking is confirmed, Trips refreshes its list, notification permission is requested, analytics records a conversion, and Booking knows about none of them. Turning everything into events is tempting because the dependency arrow disappears. The arrow disappears, the dependency stays, and now the compiler cannot see it either. Do not build a flow that waits for an answer out of a pair of events.
The types that cross a boundary should be small and poor in meaning. Android's modularization guide likewise recommends exchanging IDs between modules instead of objects. In Rota, Search does not hand Booking a whole Flight; it hands over an opaque offer token issued by the backend.
The contract in Swift
// BookingAPI.swift: everything Booking shows to the outside is in this file
import UIKit
public struct FareSelection: Sendable, Hashable {
public let offerToken: String // opaque value carried by Search, handed back to the backend by Booking
public let passengerCount: Int
public init(offerToken: String, passengerCount: Int) {
self.offerToken = offerToken
self.passengerCount = passengerCount
}
}
public struct BookingReference: Sendable, Hashable {
public let value: String
public init(_ value: String) { self.value = value }
}
public enum BookingEvent: Sendable {
case confirmed(BookingReference)
case cancelled(BookingReference)
}
@MainActor
public protocol BookingEntry {
func makeBookingFlow(for selection: FareSelection) -> UIViewController
}
public protocol BookingEventSource: Sendable {
/// Each call returns a new stream; the number of listeners is not Booking's concern.
func events() -> AsyncStream<BookingEvent>
}
The payment side is a direct call and knows nothing about Booking:
// PaymentAPI.swift
import UIKit
public struct PaymentRequest: Sendable {
public let amountMinorUnits: Int // 149990 = 1,499.90
public let currencyCode: String // ISO 4217
public let merchantReference: String
public init(amountMinorUnits: Int, currencyCode: String, merchantReference: String) {
self.amountMinorUnits = amountMinorUnits
self.currencyCode = currencyCode
self.merchantReference = merchantReference
}
}
public enum PaymentOutcome: Sendable {
case authorized(receiptID: String)
case declined
case abandoned // the user left the flow
}
@MainActor
public protocol PaymentAuthorizing {
func authorize(_ request: PaymentRequest,
presentingFrom presenter: UIViewController) async -> PaymentOutcome
}
The translation between Search and Booking sits in the composition root, the only place that knows both sides:
import Booking
import Search
import UIKit
@MainActor
final class SearchToBookingBridge {
private let booking: any BookingEntry
private let navigation: UINavigationController
init(booking: any BookingEntry, navigation: UINavigationController) {
self.booking = booking
self.navigation = navigation
}
// Search receives this method as a closure and never learns that Booking exists.
func userDidSelect(_ offer: SearchOffer, passengerCount: Int) {
let selection = FareSelection(offerToken: offer.token, passengerCount: passengerCount)
navigation.pushViewController(booking.makeBookingFlow(for: selection), animated: true)
}
}
Two Swift features help here. The package access level (SE-0386, Swift 5.9) opens a type to the targets in the same package and closes it to everything outside, which suits sharing that has not yet earned a place in the contract during a transition. internal import (SE-0409, Swift 6.0) turns a dependency leaking into your public API into a compile error: if Booking brings in the payment SDK with internal import, none of that SDK's types can appear in Booking's public signatures. In the Swift 6 language mode imports are still public by default; the InternalImportsByDefault upcoming feature flag changes the default. Verify the behaviour on your own toolchain version.
The contract in Kotlin
On Android we put the contract in a pure Kotlin module. Having no Android dependency speeds up compilation and structurally prevents Context or Fragment from leaking into the contract.
// :booking:api module, com/rota/booking/api/BookingApi.kt
package com.rota.booking.api
import kotlinx.coroutines.flow.Flow
public data class FareSelection(val offerToken: String, val passengerCount: Int)
@JvmInline
public value class BookingReference(public val value: String)
public sealed interface BookingEvent {
public data class Confirmed(val reference: BookingReference) : BookingEvent
public data class Cancelled(val reference: BookingReference) : BookingEvent
}
public interface BookingEvents {
/** Hot stream: with no listener the event is lost, and Booking does not know who listens. */
public val events: Flow<BookingEvent>
}
// :payment:api module, com/rota/payment/api/PaymentApi.kt
package com.rota.payment.api
public data class PaymentRequest(
val amountMinorUnits: Long,
val currencyCode: String,
val merchantReference: String,
)
public sealed interface PaymentOutcome {
public data class Authorized(val receiptId: String) : PaymentOutcome
public data object Declined : PaymentOutcome
public data object Abandoned : PaymentOutcome
}
public interface PaymentAuthorizer {
public suspend fun authorize(request: PaymentRequest): PaymentOutcome
}
The public keyword everywhere is there for a reason. Kotlin's default visibility is public, so adding a type to the contract requires writing nothing, and the contract grows by itself. Explicit API mode reverses that: every declaration without a stated visibility becomes a compile error.
// booking/api/build.gradle.kts
plugins {
kotlin("jvm")
}
kotlin {
explicitApi()
}
dependencies {
implementation(libs.kotlinx.coroutines.core)
}
// booking/impl/build.gradle.kts (relevant part)
dependencies {
api(project(":booking:api"))
implementation(project(":payment:api"))
implementation(project(":account:api"))
// :search and :payment:impl never appear here; only :app knows them
}
In Kotlin internal is scoped to the module, and there is no package-scoped visibility. So while you work with folders inside a single Gradle module, the compiler does not guard the boundary. We come back to this below.
Four tests for a proposed boundary
Before the whiteboard drawing turns into code, we ask four questions of every candidate boundary.
Can it be deleted. Imagine removing the module from the composition root. The build should break only in the composition root and in the consumers listed in the dependency table. Delete Trips from Rota and Booking should still compile. If it does not, an arrow has been drawn in the opposite direction too.
Can it be built and tested alone. Booking's tests should run without Payment's real implementation, using a twenty-line fake for PaymentAuthorizing. Try it with the module's own scheme on iOS and with ./gradlew :booking:impl:test on Android. If the test setup has to stand up four other domains, the boundary exists only on paper.
How many types cross it. If the contract lives in its own directory, counting is one line:
grep -rhoE "^public (struct|enum|protocol|final class|class|actor|typealias) [A-Za-z_]+" \
Sources/BookingAPI | wc -l
An absolute threshold would be dishonest; we stop and look when the number passes a dozen. The thing to watch is the trend: if the count grows every sprint, the internal model is leaking out.
Does the public API fit on one screen. The BookingAPI.swift above is forty lines. If you cannot read everything a module exposes without scrolling, the person using it cannot hold it in their head either, and they will start looking inside.
Boundaries move: folders first, modules later
Some of the boundaries you draw first will turn out wrong. That is normal, as long as fixing them is cheap. So we harden in stages.
The first stage is folders. Inside a single target, code moves into domain candidates and each folder has one contract file at its root. At this stage moving a file from one domain to another is drag and drop.
App/Sources/
Search/ SearchAPI.swift Internal/...
Booking/ BookingAPI.swift Internal/...
Payment/ PaymentAPI.swift Internal/...
Trips/ TripsAPI.swift Internal/...
Account/ AccountAPI.swift Internal/...
The compiler does not help at this stage: within a Swift target there is no barrier other than private and fileprivate, and in Kotlin internal is open to the whole module. You can hold the rule with a simple CI check. Because Kotlin imports carry the package name, even a grep does the job:
# fail the build if a file outside booking imports the booking.internal package
if grep -rnE "^import com\.rota\.booking\.internal\." app/src/main/java --include="*.kt" \
| grep -v "/com/rota/booking/"; then
echo "Booking's internal package is accessed from outside"; exit 1
fi
For a maintainable version of this, see enforcing architecture rules on Android. In Swift there are no imports inside a single target, so the same check is weak. On iOS the first real barrier is the second stage.
The second stage is one target per domain inside a single local Swift package, or one Gradle module per domain on Android. Now internal means something and the dependency directions are written in the manifest. The package access level lets you manage sharing whose boundary is not yet settled.
The third stage is separate contract modules (BookingAPI, :booking:api), separate test targets, and where needed separate repositories and ownership rules. This stage has a price, which we discuss in the cost of modularization.
When we harden a boundary: when the co-change output has shown the two sides apart for several months, when a second team starts working in that code, or when compliance requires a narrower audit scope. If none of those apply, staying at the folder stage is a deliberate decision.
Rota: from a tangled app to five domains
The starting point is familiar: Screens/, Models/, Services/ and Helpers/ folders, a Flight model used by everything, a 1,400-line BookingManager. Applying the signals above in order gives five domains.
Search is the first and easiest cut. It has its own language (offer, filter, sort), its data lives for minutes, it is the fastest-changing code and it has no compliance constraints. It knows nobody and tells the outside only that "the user picked this offer".
Payment is the second cut, justified more by constraints than by language. Card data, the 3-D Secure flow and the payment provider's SDK should sit behind one boundary. It changes slowly and every change is reviewed separately. The 64% between Checkout and Payment/Card in the co-change output shows that the boundary leaks today: BookingManager reads the card form's fields directly. Before cutting, we narrow that coupling to PaymentRequest and PaymentOutcome.
Booking owns the draft from offer selection to confirmation. The fare lock, passenger details, extras and the orchestration of the payment step live here. It knows Payment and Account through their contracts.
Trips takes everything after purchase: tickets, check-in, flight status, offline access. We separate it from Booking because of data lifetime. A draft is measured in minutes, a ticket in months. One has to work without a network, and for the other working offline makes no sense. The only link between them is BookingEvent.confirmed.
Account owns identity, the session and saved travellers. Both Booking and Trips need it, and had it stayed inside either of them, the other would depend in the wrong direction.
The cut we deliberately do not make is separating passenger details from Booking. It looks like a strong candidate: four screens, a complicated form, its own validation rules, and someone will certainly propose a PassengersModule. The signals say otherwise. 87% of the commits touching Passengers also touch Checkout. The data lives exactly as long as the draft. The validation rules depend on the fare: a passport is mandatory on international flights, and for a child fare the date of birth is checked against the day of travel. Had we split it, the contract would be a chatty interface carrying fare details that Booking knows over to Passengers. It stays as a folder inside Booking. The saved traveller list is different data: Account owns it, it lives as long as the account, and Booking reads it through the contract.
Models/Flight.swift is deleted at the end of this process. It is replaced by SearchOffer in Search, FareSelection and the draft model in Booking, and Ticket in Trips. Four types add up to more lines than the old one. In return, none of them has an optional carrying an "only set at this stage" note.
Boundary review checklist
- The reason for the boundary can be stated in one sentence without naming a screen, tab, endpoint or team.
- You can point to at least one word whose meaning changes across the boundary, or one piece of data with a different lifetime.
- Co-change output was taken for the last 12 months; the ratio across the boundary is clearly lower than the ratios inside each domain.
- Every piece of data has a single writer, and the owning domain is written down.
- Dependency directions are written as a table, there are no cycles, and service-providing domains know nobody.
- Every direct call is direct because it needs an answer; every event is an event because the publisher does not know the listener.
- IDs and small value types cross the boundary; another domain's model does not.
- Deleting the module breaks the build only in the composition root and the expected consumers.
- The module builds alone, and its tests run without other domains' implementations.
- The public API fits on one screen, and the number of types crossing the boundary is tracked.
- The boundary's current stage (folder, target, separate contract module) and the condition for moving to the next one are written down.
- At least one rejected cut is recorded together with its reasoning.
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
- Martin Fowler · Bounded Context
- Martin Fowler · Conway's Law
- Git · git-log documentation
- Swift.org · The Swift Programming Language, Access Control
- Swift Evolution · SE-0386 New access modifier package
- Swift Evolution · SE-0409 Access-level modifiers on import declarations
- Kotlin · Visibility modifiers
- Kotlin · API guidelines, Simplicity (explicit API mode)
- Android Developers · Common modularization patterns
- Adam Tornhill · code-maat
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.