Most SwiftUI examples are:
- 3 screens
- 1 ViewModel
- 0 architecture
- 100% optimism
Real products are:
- 50–200+ screens
- multiple teams
- parallel features
- constant refactors
- continuous releases
If your architecture isn’t designed for scale, it will collapse under its own weight.
This post shows how to structure large SwiftUI apps so they stay:
- navigable
- testable
- modular
- fast to build
- safe to change
This is not theory — this is how big apps actually survive.
🧠 The Core Principle
Features scale.
Files don’t.
If your app structure is based on file type:
Views/
ViewModels/
Services/
You will suffer.
Large apps are composed by features, not layers.
🧱 1. Feature-First Folder Structure
Features/
Home/
HomeView.swift
HomeViewModel.swift
HomeContainer.swift
Profile/
ProfileView.swift
ProfileViewModel.swift
ProfileContainer.swift
Settings/
SettingsView.swift
SettingsViewModel.swift
SettingsContainer.swift
Each feature owns:
- its UI
- its state
- its dependencies
- its logic
No cross-feature imports.
📦 2. Feature Containers (Again, Because This Is Everything)
Every feature has a container:
final class ProfileContainer {
let viewModel: ProfileViewModel
init(app: AppContainer) {
self.viewModel = ProfileViewModel(
repo: app.profileRepository,
analytics: app.analytics
)
}
}
This gives you:
- explicit ownership
- clean teardown
- no hidden dependencies
- easy testing
🧭 3. Central Routing Without Central Coupling
Never do:
NavigationLink(destination: ProfileView())
Instead:
enum Route {
case profile(id: String)
case settings
}
func build(route: Route) -> some View {
switch route {
case .profile(let id):
ProfileFeature(id: id)
case .settings:
SettingsFeature()
}
}
Routing is data, not UI.
🧩 4. Feature Registration Pattern
Each feature exposes a factory:
protocol Feature {
func build() -> AnyView
}
struct ProfileFeature: Feature {
func build() -> AnyView {
AnyView(ProfileView())
}
}
Your router knows only:
- feature type
- route
- not internals
This is how you avoid god routers.
🧠 5. Build Times Matter at Scale
When you hit 100+ screens:
- compile times explode
- previews slow down
- Swift type checker cries
Solutions:
- split into Swift Packages
- isolate features
- reduce cross-module imports
- avoid giant view bodies
- use small composable views
Architecture affects build time.
🧬 6. Dependency Direction Rules
Dependencies should flow:
App
→ Feature
→ Subview
Never:
Feature A → Feature B
If Feature A needs Feature B:
- extract shared logic
- move to a shared module
- never import features into features
This rule prevents circular hell.
🧪 7. Testing at Scale
Because features are isolated:
let container = ProfileContainer(app: mockApp)
let vm = container.viewModel
You can test:
- features independently
- without app boot
- without navigation
- without globals
This is impossible with layer-based architecture.
🪜 8. Gradual Refactors Without Fear
Feature isolation allows:
- deleting features safely
- rewriting features independently
- migrating architecture gradually
- parallel team work
This is how large apps evolve.
⚠️ 9. Avoid the “Shared” Folder Trap
Shared/
Utils/
Helpers/
Common/
This becomes:
The junk drawer of shame.
Instead:
- name modules explicitly
- define ownership
- enforce boundaries
- delete aggressively
Shared code should be rare and intentional.
❌ 10. Common Scaling Anti-Patterns
Avoid:
- god ViewModels
- giant routers
- global singletons
- cross-feature imports
- shared mutable state
- “just one more helper”
- dumping everything in AppState
These kill scale.
🧠 Mental Model
Think:
App = composition of features
Feature = composition of views + logic
View = pure function of state
Never:
“The app is one big thing”
It is many small, replaceable things.
🚀 Final Thoughts
Large SwiftUI apps don’t fail because of SwiftUI.
They fail because of:
- poor composition
- unclear ownership
- weak boundaries
- architectural shortcuts
When features are isolated and composed cleanly:
- teams move faster
- bugs decrease
- refactors are safe
- code stays readable
- scaling is natural
Top comments (0)