As SwiftUI apps grow, one problem always appears:
Everything ends up in one giant project.
Features depend on each other.
Services become messy.
Navigation gets tangled.
Previews slow down.
Build times increase.
Code ownership becomes unclear.
A scalable SwiftUI codebase requires feature modules β isolated pieces of your app that own their:
- UI
- ViewModels
- services
- models
- routing
- previews
This is how real production apps stay maintainable.
This post gives you the complete modular architecture pattern you can adopt in any SwiftUI app.
π§© 1. What Is a Feature Module?
A feature module is a self-contained unit representing one functional chunk of your app:
Home/
Profile/
Settings/
Feed/
Auth/
OfflineSync/
Notifications/
Each module contains:
- Views
- ViewModels
- Models
- Services
- Routing definitions
- Previews
- Mocks
A feature should be removable without breaking the app.
If you can delete a folder and nothing else breaks β it is modular.
π 2. Recommended Folder Structure
Here is the clean, scalable pattern:
AppName/
β
βββ App/
β βββ AppState.swift
β βββ AppEntry.swift
β βββ RootView.swift
β
βββ Modules/
β βββ Home/
β β βββ HomeView.swift
β β βββ HomeViewModel.swift
β β βββ HomeService.swift
β β βββ HomeRoute.swift
β β βββ HomeMocks.swift
β β
β βββ Profile/
β β βββ ProfileView.swift
β β βββ ProfileViewModel.swift
β β βββ ProfileService.swift
β β βββ ProfileRoute.swift
β β βββ ProfileMocks.swift
β β
β βββ Settings/
β β βββ SettingsView.swift
β β βββ SettingsViewModel.swift
β β βββ SettingsMocks.swift
β β
β βββ Shared/
β βββ Components/
β βββ Models/
β βββ Utilities/
β βββ Styles/
β
βββ Services/
β
βββ Resources/
No more βGod foldersβ.
Each module is its own world.
π 3. Dependency Injection at the Module Boundary
Each module declares what it needs via protocols:
protocol ProfileServiceProtocol {
func fetchProfile(id: String) async throws -> Profile
}
The module does not know about:
- real API client
- offline cache
- mock data source
- testing environment
The app injects the concrete service:
ProfileViewModel(
service: appServices.profileService
)
This creates:
- testability
- flexibility
- replacement
- isolation
π§ 4. Routing Between Modules
Each module defines its own route type:
enum ProfileRoute: Hashable {
case details(id: String)
case followers(id: String)
}
RootView aggregates module routes:
enum AppRoute: Hashable {
case profile(ProfileRoute)
case home
case settings
}
And handles navigation centrally:
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .profile(let pr): ProfileRouter.view(for: pr)
case .home: HomeView()
case .settings: SettingsView()
}
}
Each module also has a router:
struct ProfileRouter {
@ViewBuilder static func view(for route: ProfileRoute) -> some View {
switch route {
case .details(let id):
ProfileView(userID: id)
case .followers(let id):
FollowersView(userID: id)
}
}
}
Modules stay independent.
π§ͺ 5. Feature Modules Should Be Fully Previewable
Example:
#Preview("Profile Details") {
ProfileView(
viewModel: ProfileViewModel(
service: MockProfileService()
)
)
}
Modules with self-contained previews:
- speed up development
- prevent regressions
- reduce cognitive load
The previews folder becomes a mini design system per module.
π 6. Performance Wins From Modularity
A modular codebase benefits from:
β Faster build times
Only changed modules recompile.
β Safer refactoring
Modules donβt leak internal details.
β Better test isolation
Run tests module-by-module.
β Smaller mental load
New contributors understand features quickly.
β Better CI parallelization
Each module becomes a test target.
This pays off massively in apps with 10+ screens.
π 7. Feature Communication Patterns
Modules should not import each other.
Use one of these patterns instead:
AppRoute (most common)
Root coordinates navigation.Services layer
Modules communicate through shared service protocols.Event bus
Only for global side effects like analytics.Shared models
Placed explicitly under /Modules/Shared.
The key rule:
π Modules talk βupwardβ, not sideways.
π§΅ 8. Isolate Business Logic Inside Each Module
All module logic belongs in the module:
Profile/
ProfileViewModel.swift
ProfileService.swift
ProfileValidation.swift
ProfileFormatter.swift
Avoid:
- global utils
- shared state
- importing unrelated modules
This keeps code ownership clean.
π§± 9. Shared Module for Cross-App Pieces
Your /Modules/Shared folder should contain:
- base components
- typography
- theme system
- global models
- networking utilities
- animation helpers
But never feature-specific logic.
π§ 10. When Should You Modularize?
Use this checklist:
- App has 6+ screens
- Two developers or more
- Features ship independently
- You need fast previews
- You need safe refactors
- You use deep linking
- You support offline mode
- You use DI + global AppState
If you checked 3 or more β modularize.
π Final Thoughts
Modularizing a SwiftUI app transforms your codebase:
- easier to work on
- easier to test
- easier to scale
- easier to refactor
- easier to collaborate on
This is one of the biggest upgrades you can make to your architecture.
Top comments (0)