Question 5
Feature modules, SPM packages, and build graphs
Build times are growing and teams step on each other in one Xcode target. What modularization strategy would you propose, and what mistakes make modules worse?
Follow-ups
- How do you prevent circular dependencies?
Answer outline
Split the app into feature modules (SearchFeature, CheckoutFeature) plus shared kits (DesignSystem, Networking, Analytics). Each Swift Package Manager (SPM) target exposes only public types at its boundary, and implementation details stay internal. Splitting this way pays off in three ways:
- 1.Fewer merge conflicts: the shared
.pbxprojstops being a chokepoint, because feature teams rarely touch the same targets. - 2.Faster incremental builds: Xcode recompiles only the targets downstream of your change, so most edits rebuild a fraction of the app.
- 3.Compile-time boundaries: an accidental import from one feature into another fails at compile time instead of slipping through code review.
The common mistakes are circular dependencies between feature modules, a shared Utils target that turns into a dumping ground, and modularizing before the team's pain justifies the overhead.
Keep the dependency direction one way. Features depend on Domain and the shared kits, a shared kit may depend on Domain, Domain depends on nothing, and nothing depends on a feature.
Principles
- Dependencies point one way, from features down to domain, and nothing ever depends on a feature.
- Only
publictypes cross a module boundary, and everything else staysinternalso the boundary stays narrow. - Avoid a catch-all
Utilsmodule, and split by concern (DesignSystem,Logging) so each target has a clear purpose. - Prevent circular dependencies by moving shared contracts into a low-level
DomainorCoreTypestarget that both sides import.
Each target lists only what it imports, so the graph reads top down with nothing pointing back at a feature:
let package = Package(
name: "AppModules",
targets: [
.target(name: "Domain"), // depends on nothing
.target(name: "Networking",
dependencies: ["Domain"]),
.target(name: "DesignSystem"),
.target(name: "ProfileFeature",
dependencies: ["Domain", "Networking", "DesignSystem"]),
.target(name: "SearchFeature",
dependencies: ["Domain", "Networking", "DesignSystem"]),
]
)
Follow-up angles
- When two features need each other, the shared piece is almost always a protocol or a model type. Move it into a
CoreTypesorSharedInterfacestarget that both import, and the cycle disappears. - For stable shared kits, prebuilt XCFramework binaries skip recompilation entirely and can cut clean-build times substantially.



