You're asked to break up a five-year-old iOS app that has become one enormous Xcode target - how would you approach modularising it, and what does it cost you?
Modularising means carving the app into Swift packages along feature and layer boundaries so build graphs and ownership become explicit, at the cost of slower initial builds, careful dependency direction, and real discipline about what each module exposes publicly. Use this IOS answer to show the decision, trade-off, and evidence rather than a memorised definition.
What the interviewer is scoring
- Does the candidate propose a boundary scheme (feature, layer, or both) rather than just saying "split it up"
- Can they explain why build-time gains from modularisation are not automatic and depend on the dependency graph's shape
- Whether they raise the cost of getting module boundaries wrong, such as a shared "core" module that becomes a second monolith
- Do they distinguish a Swift package boundary from an access-control boundary, since public API design is most of the actual work
- Can they name a concrete first step rather than proposing to modularise everything at once
Answer
Short answer
Modularising means carving the app into Swift packages along feature and layer boundaries so build graphs and ownership become explicit, at the cost of slower initial builds, careful dependency direction, and real discipline about what each module exposes publicly.
What modularisation is actually buying you
The pitch for breaking up one giant target is usually stated as "faster builds," but that is a consequence rather than the goal. The actual goal is making dependencies explicit. In a single target, any file can import any other file's internals with no compiler-enforced boundary, so over five years the codebase accumulates dependencies nobody chose deliberately - a settings screen quietly depends on a networking type from the checkout feature because someone found it convenient once. Splitting into Swift packages forces every cross-boundary reference to go through a package's declared public API, which turns an implicit, discoverable-only-by-reading-everything dependency graph into one the build system can enforce and a person can draw on a whiteboard.
The build-time win follows from that, but only if the graph is actually shaped well. Swift's build system can skip recompiling a package whose public interface has not changed, so a change inside one leaf module's implementation does not force a rebuild of unrelated modules. If the eventual graph still has one "core" package that everything depends on and that changes constantly, you have re-created the monolith's rebuild-everything behaviour with extra package boilerplate on top - the boundary existing on disk buys nothing if the dependency shape is still a star.
Choosing the boundary scheme
Two boundary schemes are common and usually combined. A layer split separates by kind of code - a NetworkKit package, a DesignSystem package, a Persistence package - which is easy to agree on because the boundaries mirror existing team specialisation, but tends to produce packages that every feature depends on, concentrating churn at the bottom of the graph. A feature split separates by product surface - Checkout, Onboarding, Search - which maps to how teams actually ship and lets one team's change stay contained to one package, but requires deciding early how features can communicate with each other without depending on each other directly.
flowchart TD
App[App target] --> Checkout
App --> Onboarding
App --> Search
Checkout --> DesignSystem
Onboarding --> DesignSystem
Search --> DesignSystem
Checkout --> NetworkKit
Search --> NetworkKitThe edge worth looking at is that feature packages never point at each other directly. That is deliberate: if Checkout needs to trigger navigation into Search, the dependency goes through a shared abstraction (a protocol, a coordinator, or a routing token) that both depend on, rather than one feature importing another. Skipping this and letting Checkout import Search directly reintroduces the exact coupling the split was meant to remove, just now expressed as a package dependency edge instead of an implicit file reference.
What the split actually costs
Access control becomes real design work rather than a formality. Inside one target, internal visibility meant "anyone in the app can see this." Once code moves into a package, internal means "only this package," so every type and function a feature exposes across the boundary has to be deliberately marked public, and every one you mark public is now an API you are committing to support for other modules. Teams that skip this step and mark everything public to make the compiler stop complaining have not modularised anything - they have relocated the monolith's file layout without gaining the compiler-enforced boundary that was the point.
Clean-build time can get worse before it gets better. A single target compiles with whole-module optimisation and lets the compiler see everything at once; splitting into many small packages adds per-package overhead - module interface generation, linking more binaries - that can make a from-scratch build slower even while incremental builds on a small change get dramatically faster. The win is specifically for the loop most developers spend their day in - change one file, rebuild, run - not for CI's cold build, and that distinction is worth stating explicitly when someone asks why the CI pipeline did not get faster.
Circular dependencies that were invisible inside one target become build errors the moment two packages need each other, which is a feature of the change but shows up as a wave of "this won't compile" the day a team starts extracting a package that turns out to be more entangled than expected.
The pragmatic path
The trap is attempting to modularise everything at once. A five-year-old target's dependency graph is not yet known with confidence, and slicing it all up in one pass means discovering entanglement problems across every boundary simultaneously, with the whole app unbuildable until they are all resolved. The lower-risk path is to extract the module with the fewest existing dependents first - typically a design-system or utilities layer that most things use but that itself uses very little - prove the workflow and CI setup on it, then work outward to feature modules one at a time, each extraction landing as an independently shippable change rather than a multi-week freeze on the codebase.
Modularising an app is really a project to make its dependency graph explicit and enforced by the compiler - the package boundaries on disk are only worth what the dependency direction between them is actually worth.
© 2026 Preptima. Originally published at preptima.com.
Likely follow-ups
- How would you decide whether a shared UI-component module belongs at the top of the dependency graph or should not exist at all?
- What breaks if two feature modules both need to navigate to each other's screens?
- Why can splitting into local Swift packages sometimes make clean-build time worse before it makes incremental builds better?
- How would you migrate one feature out of the monolith target without freezing feature work on it for months?
Related questions
- How do you design the dependency graph, handle shared resources, and optimise build times when breaking down a massive iOS monolithic application into a modular architecture using Tuist?hardAlso on ios and architecture3 min
- How do you execute the Inverse Conway Maneuver on a 200-person engineering org that accidentally built a distributed monolith mirroring their dysfunctional silos?hardAlso on architecture3 min
- How do you implement local-first collaborative editing without the unbounded memory growth inherent to CRDTs crashing the client?hardAlso on architecture2 min
- How do you architect and implement Dynamic Feature Modules in an Android application to reduce initial download size and deliver features on demand?hardAlso on architecture3 min