After shipping 27 iOS apps, I've tried every architecture pattern the Swift community has to offer. Here's my honest review of each one — what worked, what didn't, and what I actually use today.
1. MVC (Model-View-Controller)
Where I used it: My first 5 apps
Apple's default pattern. The one every beginner starts with.
class ProfileViewController: UIViewController {
var user: User?
override func viewDidLoad() {
super.viewDidLoad()
nameLabel.text = user?.name
emailLabel.text = user?.email
// ...200 more lines in this file
}
}
The good: Simple to understand. Apple's docs use it everywhere.
The bad: "Massive View Controller" is real. My ProfileViewController hit 800 lines before I knew something was wrong.
Verdict: Fine for learning. Terrible for anything you plan to maintain.
2. MVVM (Model-View-ViewModel)
Where I used it: Apps 6-15
The community's favorite. Especially popular with SwiftUI.
class ProfileViewModel: ObservableObject {
@Published var name: String = ""
@Published var email: String = ""
@Published var isLoading = false
func loadProfile() async {
isLoading = true
let user = await userService.fetchProfile()
name = user.name
email = user.email
isLoading = false
}
}
struct ProfileView: View {
@StateObject var vm = ProfileViewModel()
var body: some View {
VStack {
if vm.isLoading {
ProgressView()
} else {
Text(vm.name)
Text(vm.email)
}
}
.task { await vm.loadProfile() }
}
}
The good: Clean separation. Views stay small. Easy to test ViewModels.
The bad: ViewModel bloat replaces ViewController bloat. You end up with ViewModels doing networking, caching, navigation, and state management all at once.
Verdict: Good default choice. But you need discipline to keep ViewModels focused.
3. TCA (The Composable Architecture)
Where I used it: 2 apps
Point-Free's opinionated framework.
@Reducer
struct ProfileFeature {
struct State: Equatable {
var name: String = ""
var isLoading = false
}
enum Action {
case loadProfile
case profileLoaded(User)
}
var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .loadProfile:
state.isLoading = true
return .run { send in
let user = await userClient.fetch()
await send(.profileLoaded(user))
}
case .profileLoaded(let user):
state.name = user.name
state.isLoading = false
return .none
}
}
}
}
The good: Incredibly testable. State management is predictable. Great for teams.
The bad: Massive learning curve. Lots of boilerplate. You're locked into Point-Free's way of thinking.
Verdict: Powerful but heavy. Overkill for solo developers and small apps.
4. Clean Architecture
Where I used it: 3 apps
Uncle Bob's layers: Entities, Use Cases, Interface Adapters, Frameworks.
The good: Maximum separation of concerns. Each layer is independently testable.
The bad: For a simple feature, you end up creating 6+ files. Repository, UseCase, ViewModel, View, Model, DTO... It's exhausting for small teams.
Verdict: Makes sense for large teams. Absurd overhead for indie developers.
5. "Pragmatic MVVM" (My Current Approach)
Where I use it: Everything now
After all that experimentation, here's what I actually settled on:
// Simple, focused ViewModel
@Observable
class ProfileViewModel {
var state = ViewState<User>.idle
private let api: APIClient
init(api: APIClient = .shared) {
self.api = api
}
func load() async {
state = .loading
do {
state = .loaded(try await api.fetchProfile())
} catch {
state = .error(error)
}
}
}
// Reusable state enum
enum ViewState<T> {
case idle, loading, loaded(T), error(Error)
}
The rules:
- One ViewModel per screen (not per component)
- ViewModels only hold state and call services
- Business logic lives in service classes
- Navigation is handled by the view layer (NavigationStack)
- No protocol abstractions unless you actually need them for testing
Why it works: It's simple enough to move fast, structured enough to maintain, and flexible enough to adapt when requirements change.
The Takeaway
Architecture isn't about picking the "best" pattern. It's about picking the right amount of structure for your team size, app complexity, and shipping speed.
Solo developer building an MVP? Start with simple MVVM. You can always add layers later.
Team of 10 building a banking app? TCA or Clean Architecture will save you from chaos.
The worst architecture is the one that slows you down without adding value.
Want more practical SwiftUI patterns? I share daily tips on t.me/SwiftUIDaily
Top comments (0)