Modular Feature Architecture in SwiftUI
Source: Dev.to
🧩 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 a 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 concrete implementations (real API client, offline cache, mock data source, testing environment). The app injects the concrete service:
ProfileViewModel(
service: appServices.profileService
)
Benefits
- Testability
- Flexibility
- Easy replacement
- Isolation
🧭 4. Routing Between Modules
Each module defines its own route type:
enum ProfileRoute: Hashable {
case details(id: String)
case followers(id: String)
}
The root view aggregates module routes:
enum AppRoute: Hashable {
case profile(ProfileRoute)
case home
case settings
}
Central navigation handling:
.navigationDestination(for: AppRoute.self) { route in
switch route {
case .profile(let pr): ProfileRouter.view(for: pr)
case .home: HomeView()
case .settings: SettingsView()
}
}
Module‑specific 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:
#Preview("Profile Details") {
ProfileView(
viewModel: ProfileViewModel(
service: MockProfileService()
)
)
}
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 brings:
- 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 can be a separate test target.
These gains are especially noticeable 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 – for global side effects like analytics.
- Shared models – placed explicitly under
Modules/Shared.
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
Never place feature‑specific logic here.
🧭 10. When Should You Modularize?
Use this checklist:
- App has 6+ screens
- Two developers or more work on the codebase
- 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
It’s one of the biggest upgrades you can make to your architecture.