Swift and SwiftUI tutorials for Swift Developers

Best SwiftUI Architecture

Since its launch in 2019, SwiftUI hasn’t just changed how we write user interfaces; it fundamentally changed how we think about data flow. If you come from the world of UIKit, Storyboards, and UIViewController, you’ve likely felt the friction: the MVC (Model-View-Controller) pattern no longer fits.

In modern iOS development, the most recurring question is no longer “How do I center this button?”, but “Where do I put the business logic?”

This tutorial dissects the most relevant architectures for the Apple ecosystem today in SwiftUI, analyzes their pros and cons, and offers a decision framework for your next project.


The Paradigm Shift: The Death of the Controller

To understand architecture in SwiftUI, we must first understand the fundamental equation governing the framework:

V=f(S)

Where V is the View and S is the State.

In UIKit (Imperative), you are responsible for manually mutating the view when data changes. In SwiftUI (Declarative), the view is a direct, immutable result of the current state. If the state changes, the view redraws itself.

Therefore, any architecture in SwiftUI must prioritize state management over view lifecycle management.


1. MVVM (Model – View – ViewModel)

The Industry Standard

MVVM has become the “default” architecture for SwiftUI. This is because the ObservableObject protocol and the @Published property wrapper seem to have been designed specifically to fit this pattern.

How it works

  • Model: Simple data structures (Structs) and pure business logic. It knows nothing about the UI.
  • View: SwiftUI structures that observe the ViewModel. They contain no complex logic, only presentation.
  • ViewModel: Classes (class) that conform to ObservableObject. They transform model data into something the view can display and handle user actions (intents).

Practical Implementation

// 1. Model
struct TaskItem: Identifiable {
    let id: UUID
    var title: String
    var isCompleted: Bool
}

// 2. ViewModel
class TaskListViewModel: ObservableObject {
    @Published var tasks: [TaskItem] = []
    
    func addTask(title: String) {
        let new = TaskItem(id: UUID(), title: title, isCompleted: false)
        tasks.append(new)
    }
    
    func completeTask(_ task: TaskItem) {
        if let index = tasks.firstIndex(where: { $0.id == task.id }) {
            tasks[index].isCompleted.toggle()
        }
    }
}

// 3. View
struct TaskListView: View {
    @StateObject private var viewModel = TaskListViewModel()
    
    var body: some View {
        List(viewModel.tasks) { task in
            HStack {
                Text(task.title)
                Spacer()
                Image(systemName: task.isCompleted ? "checkmark.circle" : "circle")
                    .onTapGesture {
                        viewModel.completeTask(task)
                    }
            }
        }
    }
}

2. TCA (The Composable Architecture)

The Power of Unidirectional Flow

Created by Point-Free, TCA is a library that brings Redux concepts (popular in React) to Swift. It is a dogmatic architecture: it tells you exactly how to do things.

Key Concepts

  1. State: A structure that describes the entire state of your feature.
  2. Action: An enum listing all possible events (button pressed, API response received).
  3. Reducer: A pure function that takes the current state and an action, and returns a new state.
  4. Store: The object that holds the state and receives actions.

Why use TCA?

The biggest advantage is testability. Since reducers are pure functions (no hidden side effects), testing your logic is trivial. Furthermore, it handles dependencies (API clients, dates, UUIDs) explicitly.

Conceptual Example (Simplified)

struct FeatureState: Equatable {
    var count = 0
}

enum FeatureAction: Equatable {
    case decrement
    case increment
}

struct FeatureEnvironment {}

let featureReducer = Reducer<FeatureState, FeatureAction, FeatureEnvironment> { state, action, _ in
    switch action {
    case .decrement:
        state.count -= 1
        return .none
    case .increment:
        state.count += 1
        return .none
    }
}

The Verdict on TCA

  • Pros: Unmatched testability, Time Travel Debugging, breaking down complex problems into small modules.
  • Cons: Very high learning curve, verbosity (lots of boilerplate code), dependency on a third-party library (though very well maintained).

3. Clean Architecture + MVVM

The Enterprise Option

For large applications meant to last for years, sometimes MVVM is not enough. This is where we apply Clean Architecture principles (Uncle Bob) on top of the MVVM layer.

The key here is Dependency Injection and strict layer separation:

  1. Presentation: (Your Views and ViewModels).
  2. Domain: (Use Cases / Interactors and Entities).
  3. Data: (Repositories and Data Sources).

What does it look like in SwiftUI?

The ViewModel no longer contains the logic. The ViewModel simply calls a UseCase.

// Domain Layer (UI Agnostic)
protocol GetUserUseCase {
    func execute() async throws -> User
}

// Presentation Layer
class UserViewModel: ObservableObject {
    @Published var user: User?
    private let getUserUseCase: GetUserUseCase // Injected
    
    init(getUserUseCase: GetUserUseCase) {
        self.getUserUseCase = getUserUseCase
    }
    
    func load() async {
        self.user = try? await getUserUseCase.execute()
    }
}

The Verdict on Clean Arch

  • Pros: Total framework independence, highly reusable code, maximum scalability for large teams.
  • Cons: Over-engineering for simple apps. Too many files to accomplish a simple task.

4. The “Pure SwiftUI” Pattern (MV)

Less is More

There is a growing current suggesting that for many views, you don’t need a ViewModel. SwiftUI has powerful tools like @State@Binding, and @Environment that make the middle layer redundant for simple components.

If your view only displays data and has local animations, creating an extra ObservableObject class is a waste of memory and code.

When to use it:

  • Prototypes.
  • Leaf views that only receive data.
  • Reusable UI components (Buttons, Cells).

Technical Comparison

FeatureMVVMTCAClean + MVVMPure SwiftUI
Learning CurveMediumHighMedium-HighLow
BoilerplateMediumHighHighMinimal
TestabilityGoodExcellentExcellentLow (Logic in View)
ScalabilityGoodVery GoodExcellentLow
AdoptionMassiveGrowingEnterpriseIndie / Niche

Export to Sheets


How to Choose the Right Architecture

There is no “best” architecture, only the most suitable one for your context. Here is my decision framework:

Scenario A: You are an Indie Developer or a small Startup

Recommendation: Standard MVVM. You need speed. MVVM is robust enough not to create spaghetti code, but fast enough to iterate. Use @StateObject for your main screens and break views into small components.

Scenario B: Banking, Healthcare, or Enterprise App

Recommendation: Clean Architecture + MVVM or TCA. Here, correctness is more important than speed. You need to separate business rules (e.g., interest calculation) from the interface. If your team is willing to learn, TCA offers state safety guarantees that are invaluable in these sectors.

Scenario C: Complex App with heavy shared state

Recommendation: TCA. If your app has a shopping cart that needs to update from 5 different screens, a music player that persists throughout the app, or deep navigation flows, TCA’s centralized state management will save you from many headaches associated with @EnvironmentObject.


Conclusion

SwiftUI is a young framework, and “best practices” are still evolving. However, the most important lesson is consistency.

  1. If you choose MVVM, ensure you don’t put business logic in the View.
  2. If you choose TCA, respect the unidirectional flow and don’t look for shortcuts.
  3. If you choose Clean, keep your domain layers pure.

A bad architecture followed consistently is better than three good architectures mixed in the same project.

At the end of the day, the architecture should serve you and your team to move faster and with more confidence, not to slow you down with code bureaucracy.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

NavigationStack vs NavigationView in SwiftUI

Next Article

How to animate SF Symbols in SwiftUI

Related Posts