DEV Community

5 SwiftUI Architectures I've Used in Production (And Which One Won)

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
    }
}
Enter fullscreen mode Exit fullscreen mode

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() }
    }
}
Enter fullscreen mode Exit fullscreen mode

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
            }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

The rules:

  1. One ViewModel per screen (not per component)
  2. ViewModels only hold state and call services
  3. Business logic lives in service classes
  4. Navigation is handled by the view layer (NavigationStack)
  5. 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)