Modular Mobile Architecture: Features as Frameworks
How to structure mobile applications as composable feature modules — enabling parallel development, independent testing, and flexible deployment.
The Monolith Problem
Every mobile application starts simple. A few screens, a handful of features, one team. Then growth happens. More features. More developers. More complexity. Before you know it, you have a monolithic codebase where:
- Changing one feature breaks another
- Build times stretch to 15+ minutes
- New developers take weeks to become productive
- Testing requires running the entire app
- Teams step on each other's code constantly
The solution? Treat features as frameworks — independent, versioned, composable modules that can be developed, tested, and deployed in isolation.
What Is a Feature Framework?
A feature framework is a self-contained module that encapsulates:
- UI Components — Screens, views, and view controllers
- Business Logic — Use cases, services, and domain models
- Data Layer — Repositories, network calls, and local storage
- Tests — Unit, integration, and UI tests
From the container app's perspective, a feature is a black box with well-defined inputs (dependencies) and outputs (public interfaces).
Benefits of Feature Modularization
1. Parallel Development
With clear boundaries, teams can work independently:
- Team A develops the Payments feature
- Team B develops the Profile feature
- Team C develops the Messaging feature
No merge conflicts. No coordination meetings. No waiting.
2. Faster Build Times
Modular architectures enable:
- Incremental builds — Only rebuild changed modules
- Parallel compilation — Modules build simultaneously
- Cached artifacts — Unchanged modules don't rebuild
A 15-minute monolithic build becomes 2-3 minutes of incremental builds.
3. Independent Testing
Each feature can be tested in isolation:
// Feature-level integration test
class AuthenticationFeatureTests: XCTestCase {
var sut: AuthenticationCoordinator!
var mockNetwork: MockNetworkService!
func testLoginFlow() async throws {
mockNetwork.stub(.login, response: .success(mockUser))
let result = await sut.login(email: "test@example.com",
password: "password")
XCTAssertEqual(result, .authenticated(mockUser))
}
}
No need to launch the entire app. No test pollution from other features.
4. Code Ownership
Clear module boundaries enable clear ownership:
| Feature | Team | Codeowners |
|---|---|---|
| Authentication | Identity | @identity-team |
| Payments | Commerce | @commerce-team |
| Messaging | Engagement | @engagement-team |
Pull requests automatically route to the right reviewers.
5. Flexible Deployment
Features can be:
- Included or excluded per brand
- Released independently (with feature flags)
- A/B tested without full app releases
Architecture Patterns
The Coordinator Pattern
Features expose a coordinator as their public interface:
public protocol AuthenticationCoordinating {
func start() -> UIViewController
var authStatePublisher: AnyPublisher<AuthState, Never> { get }
func logout() async
}
public class AuthenticationCoordinator: AuthenticationCoordinating {
private let dependencies: AuthDependencies
public init(dependencies: AuthDependencies) {
self.dependencies = dependencies
}
public func start() -> UIViewController {
let viewModel = LoginViewModel(
authService: dependencies.authService,
analytics: dependencies.analytics
)
return LoginViewController(viewModel: viewModel)
}
}
Dependency Injection
Features declare their dependencies through protocols:
public struct AuthDependencies {
public let networkService: NetworkServiceProtocol
public let secureStorage: SecureStorageProtocol
public let analytics: AnalyticsProtocol
public init(
networkService: NetworkServiceProtocol,
secureStorage: SecureStorageProtocol,
analytics: AnalyticsProtocol
) {
self.networkService = networkService
self.secureStorage = secureStorage
self.analytics = analytics
}
}
The container app provides concrete implementations:
let authDependencies = AuthDependencies(
networkService: AppNetworkService(),
secureStorage: KeychainStorage(),
analytics: FirebaseAnalytics()
)
let authCoordinator = AuthenticationCoordinator(dependencies: authDependencies)
Feature Flags Integration
Features should respect feature flag state:
public class PaymentsCoordinator: PaymentsCoordinating {
private let featureFlags: FeatureFlagsProtocol
public func start() -> UIViewController {
if featureFlags.isEnabled(.newCheckoutFlow) {
return NewCheckoutViewController(...)
} else {
return LegacyCheckoutViewController(...)
}
}
}
Project Structure
A well-organized modular project:
App/
Features/
Authentication/
Package.swift
Sources/
Public/
AuthenticationCoordinator.swift
AuthDependencies.swift
Models/
Internal/
LoginViewModel.swift
LoginViewController.swift
AuthService.swift
Tests/
AuthServiceTests.swift
LoginViewModelTests.swift
Payments/
Profile/
Core/
Networking/
Storage/
Analytics/
Container/
AppDelegate.swift
DependencyContainer.swift
Swift Package Manager Structure
Modern iOS projects use SPM for modularization:
// Package.swift for Authentication feature
let package = Package(
name: "Authentication",
platforms: [.iOS(.v15)],
products: [
.library(name: "Authentication", targets: ["Authentication"]),
],
dependencies: [
.package(path: "../Core"),
],
targets: [
.target(
name: "Authentication",
dependencies: ["Core"]
),
.testTarget(
name: "AuthenticationTests",
dependencies: ["Authentication"]
),
]
)
Communication Between Features
Features should not depend on each other directly. Use these patterns:
1. Coordinator Callbacks
protocol PaymentsCoordinatorDelegate: AnyObject {
func paymentsDidComplete(result: PaymentResult)
func paymentsDidRequestLogin()
}
2. Shared Event Bus
enum AppEvent {
case userLoggedIn(User)
case userLoggedOut
case purchaseCompleted(Product)
}
protocol EventBusProtocol {
func publish(_ event: AppEvent)
func subscribe<T>(_ eventType: T.Type) -> AnyPublisher<T, Never>
}
3. Deep Link Router
protocol DeepLinkHandler {
func canHandle(_ url: URL) -> Bool
func handle(_ url: URL) -> UIViewController?
}
// Each feature registers its handler
class AuthDeepLinkHandler: DeepLinkHandler {
func canHandle(_ url: URL) -> Bool {
url.path.hasPrefix("/auth")
}
}
Common Challenges
Challenge 1: Shared UI Components
Problem: Multiple features need the same buttons, inputs, and styles.
Solution: Create a Design System module that all features depend on:
Core ← DesignSystem ← Features
Challenge 2: Circular Dependencies
Problem: Feature A needs Feature B, and Feature B needs Feature A.
Solution: Extract the shared dependency into Core, or use protocol-based abstraction:
// In Core
protocol UserProvider {
var currentUser: User? { get }
}
// Feature A implements, Feature B consumes
Challenge 3: Versioning Complexity
Problem: Which version of Feature A works with which version of Feature B?
Solution: Semantic versioning with compatibility matrices, or monorepo with lockstep versioning.
Challenge 4: Developer Experience
Problem: Working on one feature requires understanding the entire project.
Solution:
- Feature demo apps for isolated development
- Comprehensive README in each feature
- Automated dependency graph visualization
Migration Strategy
Migrating from monolith to modular architecture:
Phase 1: Establish Boundaries
- Identify logical feature groupings
- Document dependencies between components
- Create module interface contracts
Phase 2: Extract Core
- Move shared utilities to Core module
- Establish networking, storage, analytics as separate modules
- Define protocols for cross-cutting concerns
Phase 3: Extract Features (One at a Time)
- Start with the most isolated feature
- Create new module, move code, update imports
- Verify with comprehensive testing
- Repeat for remaining features
Phase 4: Optimize
- Enable incremental builds
- Set up cached artifact sharing
- Implement feature-specific CI pipelines
Metrics for Success
Track these to ensure modularization is working:
- Build time reduction — Target 50%+ improvement
- Feature isolation — Zero dependencies between feature modules
- Test coverage per feature — Each feature independently tested
- PR cycle time — Should decrease with focused reviews
- Developer onboarding time — New devs productive faster
Conclusion
Feature modularization is not just an architectural pattern — it's an organizational pattern. Clear module boundaries enable clear team boundaries, clear ownership, and clear accountability.
The investment in modularization pays dividends in:
- Developer velocity
- Code quality
- Team autonomy
- Release confidence
Start with your most painful feature, prove the pattern, then systematically modularize the rest.
Patterns refined through years of enterprise mobile development across multiple platforms and team sizes.
