Modularizing a Legacy iOS App Without Stopping Delivery
Splitting a monolithic app into modules while the team keeps shipping features every sprint. What actually decides module boundaries, and what breaks first when you get them wrong.
I spent a year migrating a smart-pet-device company’s app off a single-target Objective-C codebase, module by module, while the team kept shipping releases the whole time. No feature freeze, no six-month rewrite branch nobody could merge. +30% on perf and UX metrics by the end, and every release in between still went out on schedule.
Modularization gets pitched as a build-time problem: split the app so Xcode compiles faster. That’s real, but it’s not the reason it’s hard. The hard part is deciding where the module boundaries actually go, because a wrong boundary costs you for years and a stop-the-world rewrite to fix it later is exactly the thing you were trying to avoid in the first place.
Boundaries follow ownership, not folders
The instinct is to modularize along the existing folder structure: pull Networking/ into a module, then UI/, then Models/. That produces modules that compile faster and change nothing else, because the coupling that was implicit in one target is now explicit across module boundaries, still tightly coupled, just with an extra build step.
The boundary that actually holds is the one that follows who owns the code and how often it changes independently. On the Whistle migration, the module split that survived was the one where a screen’s data layer, its business logic, and its UI moved together as one unit, because that’s the unit a single engineer or pair actually owned and shipped changes to. A “shared utilities” module that everyone imports and nobody owns is where modularization efforts go to die. It becomes a second monolith with extra ceremony.
What you extract first is not what you’d guess
The natural first move is extracting the biggest, most obviously reusable piece: the design system, the networking layer. Those are the wrong first extractions in a legacy codebase, because they’re also the pieces with the most existing call sites, so pulling them out first means touching the most files before you’ve proven the approach works at all.
The extraction that actually de-risks the migration is the smallest, most isolated feature module with the fewest existing dependents. Get one small module fully extracted, its public interface defined, its internal implementation hidden, and shipped in a real release. That’s the proof of the pattern the rest of the migration reuses. Starting with the design system instead means the team is debugging both the modularization approach and a huge diff at the same time, and when something breaks in week three, nobody can tell which problem they’re looking at.
The interim is the actual project, not a phase you pass through
The same underestimation from the SwiftUI migration showed up here too: the framework choice was the easy part of that decision, and the dual-stack interim ran months longer than planned because I scoped the work around “get to the target architecture” instead of budgeting the interim itself as its own tracked phase.
Modularization has the same failure mode, worse, because a half-modularized app has both a monolith target and a growing set of extracted modules depending on each other in ways nobody fully mapped. Every new feature during the interim has to decide: does this go in the module structure or the legacy target, and does that decision get revisited when the module structure changes shape next month. Budget the interim with an explicit exit criterion, not “modularization is 60% done,” because 60% done on a dependency graph nobody diagrammed is not a real number.
What actually breaks delivery mid-migration
Build time regressions, briefly, as modules settle into their real dependency graph before it stabilizes. Merge conflicts in the one file every module still touches, usually the app’s root composition point where modules get wired together, until that root gets its own clear ownership too. And the team’s own confidence: engineers who’ve shipped fast in the old monolith for years feel slower for a few sprints inside the new module boundaries, before the module structure starts paying that speed back. That confidence dip is real and worth naming out loud to the team before it happens, not after someone asks why velocity looks worse this sprint.
The discipline that actually holds
Define a module’s public interface before extracting its implementation. Ship one small module fully, in a real release, before touching a second one. Track the interim as its own workstream with an exit criterion, the same discipline a short, enforceable CLAUDE.md needs for a different reason but the same principle: name the actual constraint instead of the abstract goal. And accept that the ownership map, not the folder structure, is what decides where a boundary actually goes.
If you’re staring at a monolithic codebase and a roadmap that can’t pause for a rewrite, that’s exactly the kind of call I help teams make in strategy sessions, here.
What’s the module in your own codebase that everyone agrees should be split out, and what’s actually stopping the team from starting?