Greetings, traveler!
The application I joined had a long history. Development started around 2015, with a large Objective-C codebase that gradually evolved as different teams took ownership over the years. At some point Swift was introduced, but the transition relied on a bridging header, which kept both worlds tightly coupled.
As the codebase changed hands, architectural consistency eroded. Multiple patterns coexisted, often solving similar problems in different ways. Some features followed MVC, others leaned toward MVVM, and navigation logic was scattered across view controllers and coordinators with no clear boundaries.
I joined as one of the first members of a new team that inherited this system. The team grew quickly, and the problems became visible almost immediately. Merge conflicts became a daily routine. Code reviews required a significant amount of context switching. Small changes often triggered unrelated regressions.
At that point, it became clear that incremental fixes would not be enough. I proposed approaching the problem in stages, which we adopted as a team.
The first step was to stabilize the UI layer. We moved toward a consistent MVVM + Coordinator structure, which helped isolate presentation logic and made navigation flows explicit. The second step focused on dependency management. We introduced a composition root to centralize object creation and reduce implicit dependencies scattered across the codebase.
These changes did not solve the monolith, but they created a structure that could support further decomposition.
One major obstacle remained. The core of the application still relied heavily on Objective-C. Integrating that layer with Swift Package Manager was technically possible, but it would introduce friction and long-term maintenance costs. At that point, we aligned on rewriting the core components in Swift. This was a substantial investment, though it removed a blocker for modularization.
Preparing for modularization
By the time we reached this stage, the application contained more than 300 screens and a large number of shared components. There were few reliable guides on how to modularize a system of that size in an iOS context. Most decisions had to be validated through experimentation.
A significant portion of the work happened away from the code editor. I spent time sketching the system, mapping dependencies, and identifying natural boundaries. The goal was to define a structure that could scale without turning into a fragmented collection of targets.
We approached the transition incrementally. Rewriting everything at once would have been risky and difficult to coordinate across a growing team.
Establishing the foundation layer
The first step was to extract the foundation layer. These modules contained the lowest-level building blocks of the system.
Typical examples included:
- Core data models shared across features
- Networking primitives and request abstractions
- Serialization and decoding logic
- Basic utilities that did not depend on application context
A strict rule guided this layer. Foundation modules could not import anything from higher levels. They formed the base of the dependency graph and remained stable over time.
This constraint forced us to think carefully about what belonged there. Whenever a piece of logic required knowledge about user flows or feature-specific behavior, it was a sign that it did not belong in the foundation.
Extracting service modules
Once the foundation was in place, we moved to the next layer: services.
Service modules depended on foundation modules and provided reusable capabilities across the application. These were components that did not represent user scenarios but were still shared widely.
Examples included:
- API clients built on top of networking primitives
- Persistence layers handling caching and local storage
- Analytics and logging services
- Business logic that applied across multiple features
A useful guideline emerged during this phase. If a component could be reused without referencing a specific user flow, it likely belonged in a service module.
We kept public interfaces narrow. Each service exposed only what was necessary for consumption. When a service started growing beyond a clear responsibility, we treated it as a signal to revisit its boundaries.
Introducing feature modules
The next step was to define feature modules.
A feature module represented a complete user scenario with a clear outcome. Examples included onboarding, payments, account management, or transaction history. Each feature owned its internal flow, screens, and presentation logic.
At this stage, we made a mistake that shaped several iterations of the architecture.
Each feature was wrapped in its own coordinator, and those coordinators were allowed to trigger other feature coordinators directly. To support this, feature modules imported other feature modules.
At first, this approach seemed practical. It allowed navigation to be expressed locally. Over time, the consequences became apparent.
The dependency graph became increasingly complex. Circular dependencies started to appear. A circular dependency occurs when module A depends on module B, while module B also depends on module A, either directly or through a chain of intermediate modules. This creates ambiguity in build order and complicates reasoning about the system.
The impact went beyond build configuration. SwiftUI previews began to fail. Previews rely on a consistent and resolvable dependency graph. When modules reference each other in cycles or through inconsistent imports, the preview system struggles to isolate and render individual components.
Navigation logic also became harder to follow. Instead of a clear flow, it was distributed across multiple coordinators with implicit relationships.
Attempting interface modules
Our first attempt to fix this involved introducing interface modules. The idea was to define protocols in a separate module and depend on those abstractions instead of concrete feature implementations. In theory, this would break direct dependencies between features.
In practice, it introduced additional complexity.
The number of modules increased. Debugging required jumping between interface definitions and implementations. Build times did not improve in a meaningful way, since the underlying coupling remained. The system looked more modular on the surface, though the mental model became harder to manage.
After a short period, we decided to abandon this approach.
Centralizing navigation with an app coordinator
The approach that worked for us was to move cross-feature navigation out of feature modules entirely.
We made an app-level coordinator responsible for orchestrating transitions between features. Feature modules no longer imported each other. Instead, they exposed entry points and communicated outcomes through well-defined outputs.
When a feature completed its work and required a transition, it signaled that intent. The app coordinator handled the navigation and passed any required data to the next feature.
This change had several immediate effects.
The number of dependencies between feature modules dropped significantly. The dependency graph became easier to understand. SwiftUI previews stabilized, since individual features could now be built and rendered in isolation.
Feature modules became more self-contained. Each module defined how it was constructed and what inputs it required, often through a dedicated assembly API.
Scaling to 130+ modules
Over time, the system grew to more than 130 modules. The impact on team workflows was noticeable. Merge conflicts decreased substantially. Changes were more localized. Developers could work in parallel without constantly stepping on each other’s changes.
Code reviews became more focused. Reviewing a change in a single module required less context about the rest of the system. Architectural boundaries became clearer, which improved consistency across features.
The structure also encouraged better discipline. When a module had a clear responsibility and a limited public interface, it became harder to introduce ad hoc dependencies.
Measuring build time improvements
We tracked build performance to understand the impact of modularization.
Several types of builds were relevant:
- Clean builds, where the entire project is compiled from scratch
- Incremental builds, triggered after small changes
- Module-level rebuilds, where only affected modules are recompiled
Before modularization, even small changes could trigger large portions of the codebase to rebuild. The lack of boundaries made it difficult for the build system to isolate changes.
After modularization, incremental builds improved the most. Changes within a feature module typically affected only that module and its direct dependents.
In our measurements:
- Clean build time improved by roughly 15–20%
- Incremental build time improved by around 35% on average
- In some scenarios, rebuilding a single feature became several times faster
The exact numbers varied depending on the change, though the overall trend remained consistent.
Conclusion
Decomposing a large monolithic application required more than splitting the project into smaller targets. The process involved establishing clear boundaries, defining dependency direction, and revisiting architectural decisions that had accumulated over time.
The most important changes were not mechanical. Moving navigation out of feature modules, enforcing strict rules for the foundation layer, and keeping service modules focused had a greater impact than any specific tooling choice.
The resulting system became easier to reason about and more predictable to work with. Build times improved, though the larger benefit came from reducing friction in daily development and creating a structure that could support further growth without repeating the same patterns that led to the monolith.
