Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 056 / 100

App architecture options for SwiftUI: a practical guide

MVVM, TCA and simple models.

Expona Admin9 min read

Introduction

By day 56 of building SwiftUI apps, you've likely noticed that your project structure matters as much as the code itself. When you move beyond a single-screen prototype, architectural decisions shape how easy your app is to test, maintain, and extend. In this guide, we'll explore app architecture options for SwiftUI: a practical guide to help you choose the right approach for your team's specific needs—and know when to evolve from one pattern to another.

Understanding your architecture options

There's no single "correct" architecture for SwiftUI apps. The right choice depends on your app's complexity, team size, and testing requirements. We typically recommend one of three main approaches, each with different trade-offs.

1. @Observable models for most apps

If you're building a small to medium-sized app with straightforward state management, the simplest approach often works best. Swift's @Observable macro (introduced in iOS 17) lets you create plain models that SwiftUI automatically tracks for changes. This fits most production apps because it requires minimal boilerplate and keeps your views clean.

Here's a typical setup:

swift
import Observation

@Observable
final class TodoListViewModel {
    var todos: [Todo] = []
    var isLoading = false
    var errorMessage: String?
    
    func loadTodos() async {
        isLoading = true
        errorMessage = nil
        
        do {
            todos = try await TodoService.fetchTodos()
        } catch {
            errorMessage = error.localizedDescription
        }
        
        isLoading = false
    }
    
    func deleteTodo(_ todo: Todo) async {
        todos.removeAll { $0.id == todo.id }
        
        do {
            try await TodoService.delete(todo.id)
        } catch {
            errorMessage = error.localizedDescription
            todos.append(todo) // Rollback
        }
    }
}

struct TodoListView: View {
    @State private var viewModel = TodoListViewModel()
    
    var body: some View {
        List {
            if viewModel.isLoading {
                ProgressView()
            } else if let error = viewModel.errorMessage {
                Text("Error: \(error)")
                    .foregroundColor(.red)
            } else {
                ForEach(viewModel.todos) { todo in
                    TodoRow(todo: todo)
                }
                .onDelete { indexSet in
                    Task {
                        for index in indexSet {
                            await viewModel.deleteTodo(viewModel.todos[index])
                        }
                    }
                }
            }
        }
        .task {
            await viewModel.loadTodos()
        }
    }
}

This approach shines when you're shipping fast and your feature set is well-defined. The viewModel holds business logic, the view remains a rendering layer, and SwiftUI's reactivity handles the rest. We've used this pattern across multiple e-commerce projects with 10–20 screens and it performs reliably.

2. MVVM for testable screens with logic

When your screen needs complex logic—conditional state flows, multiple async operations, or intricate user interactions—MVVM gives you a clearer separation between presentation and business logic. The key difference from @Observable models is that you explicitly manage bindings and often use @Published properties (from Combine) or @Observable with explicit state channels.

This approach excels when your team needs high test coverage or when you're integrating with legacy backend APIs that require sophisticated retry logic:

swift
import Combine
import Observation

@Observable
final class CheckoutViewModel {
    var cartItems: [CartItem] = []
    var selectedShippingMethod: ShippingMethod?
    var promoCode: String = ""
    var isProcessing = false
    var validationErrors: [String] = []
    
    private let cartService: CartService
    private let paymentProcessor: PaymentProcessor
    
    init(
        cartService: CartService,
        paymentProcessor: PaymentProcessor
    ) {
        self.cartService = cartService
        self.paymentProcessor = paymentProcessor
    }
    
    func validateOrder() -> Bool {
        validationErrors.removeAll()
        
        guard !cartItems.isEmpty else {
            validationErrors.append("Cart is empty")
            return false
        }
        
        guard selectedShippingMethod != nil else {
            validationErrors.append("Select a shipping method")
            return false
        }
        
        return validationErrors.isEmpty
    }
    
    func processPayment() async {
        guard validateOrder() else { return }
        
        isProcessing = true
        defer { isProcessing = false }
        
        do {
            let order = Order(
                items: cartItems,
                shippingMethod: selectedShippingMethod!,
                promoCode: promoCode.isEmpty ? nil : promoCode
            )
            
            let result = try await paymentProcessor.process(order)
            
            // Handle success
            await cartService.clearCart()
        } catch {
            validationErrors.append(error.localizedDescription)
        }
    }
}

// Easy to unit test
final class CheckoutViewModelTests: XCTestCase {
    func testValidateOrderWithEmptyCart() {
        let viewModel = CheckoutViewModel(
            cartService: MockCartService(),
            paymentProcessor: MockPaymentProcessor()
        )
        
        let isValid = viewModel.validateOrder()
        XCTAssertFalse(isValid)
        XCTAssert(viewModel.validationErrors.contains("Cart is empty"))
    }
}

MVVM works well for complex features because you separate validation, API calls, and state mutations into discrete methods that are easy to test in isolation. We've found this structure particularly valuable in CRM and SaaS products where business rules are strict and regression risk is high.

3. The Composable Architecture for large, structured apps

If you're building a large app with many interconnected features, strict state management requirements, or a team that needs predictable patterns across 30+ screens, The Composable Architecture (TCA) provides that structure. It uses reducers, actions, and effects to make every state change explicit and testable.

TCA is more opinionated than MVVM, but that's intentional:

swift
import ComposableArchitecture

@Reducer
struct TodoFeature {
    @ObservableState
    struct State: Equatable {
        var todos: [Todo] = []
        var isLoading = false
        var selectedTodoID: Todo.ID?
    }
    
    enum Action {
        case loadTodos
        case todosResponse([Todo])
        case selectTodo(Todo.ID)
        case deleteTodo(Todo.ID)
        case deletionResponse(Result)
    }
    
    @Dependency(\.todoClient) var todoClient
    
    var body: some ReducerOf {
        Reduce { state, action in
            switch action {
            case .loadTodos:
                state.isLoading = true
                return .run { send in
                    let todos = try await todoClient.fetchTodos()
                    await send(.todosResponse(todos))
                }
                
            case let .todosResponse(todos):
                state.isLoading = false
                state.todos = todos
                return .none
                
            case let .selectTodo(id):
                state.selectedTodoID = id
                return .none
                
            case let .deleteTodo(id):
                return .run { send in
                    let result = Result { try await todoClient.delete(id) }
                    await send(.deletionResponse(result))
                }
                
            case let .deletionResponse(.success):
                state.todos.removeAll { $0.id == state.selectedTodoID }
                state.selectedTodoID = nil
                return .none
                
            case let .deletionResponse(.failure(error)):
                // Handle error
                return .none
            }
        }
    }
}

TCA excels at managing complex flows across multiple screens, handling deep linking, and ensuring consistent undo/redo behavior. Every action is logged, every effect is testable, and state mutations are predictable. We've seen this pay off significantly in ride-hailing and travel apps where user flows are intricate and bugs are expensive.

Start simple, add structure as complexity grows

The biggest mistake we see teams make is choosing an architecture based on ideology rather than actual project needs. Start with @Observable models. If you find yourself repeating validation logic across views, move that to a viewModel. If you need strict action logging or complex state coordination across five screens, then evaluate TCA.

Your day-30 architecture probably won't be your day-180 architecture, and that's fine. Refactoring from simple @Observable to MVVM is straightforward. Introducing TCA later is harder but still manageable if you've kept logic out of views.

A practical signal: if your screen's View file exceeds 200 lines, extract the logic. If multiple screens share similar state mutations, consider a shared coordinator. If you can't explain the state flow in a sentence, you need more structure.

Maintain consistency across your app

Whatever pattern you choose, apply it consistently. Mixing three different architectural styles across your codebase creates cognitive load for any developer touching the code. New team members spend time reverse-engineering why screen A uses TCA, screen B uses MVVM, and screen C is just a view with a few state variables.

Consistency also makes code review easier. Reviewers can spot anti-patterns quickly when everyone follows the same rulebook. In our experience, consistent mediocre architecture beats inconsistent clever architecture every time.

Document your choice in an architecture decision record (ADR) or a short README in your project. Include:

  • Which pattern you've chosen (e.g., @Observable for most screens)
  • When to upgrade to something more complex (e.g., MVVM for forms with validation)
  • Where exceptions exist and why
  • An example of each pattern implemented in your codebase

Keep testability central to your choice

Regardless of architecture, the test litmus is simple: can you test your business logic without launching a simulator? If your validation, state mutations, or async flows require UI tests, your architecture is probably mixing concerns.

With @Observable or MVVM:

  • Inject dependencies (services, APIs) into your viewModel constructor
  • Use mock implementations in tests
  • Test the viewModel directly, not through views

With TCA:

  • Use dependency containers to swap real and mock services
  • Write reducer tests that assert state transitions given actions
  • Test effects (async operations) with TestStore

We typically aim for 70–80% unit test coverage on viewModels and reducers, and 20–30% integration/UI test coverage on critical flows. That ratio keeps your suite fast while catching real bugs.

Common mistakes

  • Putting all logic in views. Views should render state, not compute it. If you're calling an API inside a view body or performing calculations on @State variables every render, extract that to a viewModel.
  • Creating viewModels with no dependencies. If your viewModel can't be instantiated with mock services, you can't test it. Always inject CartService, AuthService, etc. as constructor parameters.
  • Mixing reactive patterns. Don't combine @Observable, @Published, and Combine publishers in the same viewModel unless you have a clear reason. Pick one pattern and stick to it.
  • Ignoring performance of state changes. Large state trees in @Observable can cause unnecessary view recalculations. Consider splitting state into smaller, focused viewModels if you notice rendering lag.
  • Over-engineering early. You don't need TCA for a five-screen app. Start simple and refactor when complexity demands it.

Key takeaways

  • Begin with @Observable models for most production apps—minimal boilerplate, maximum simplicity.
  • Upgrade to MVVM when a screen's logic becomes complex enough to merit isolation and unit testing.
  • Consider TCA only for large apps (20+ screens) with complex state flows or teams that benefit from strict patterns.
  • Prioritize consistency over perfection: pick one approach and apply it uniformly across your codebase.
  • Always keep business logic out of views and inject your dependencies; this ensures your code is testable regardless of architecture.
  • Refactor early when you spot code duplication or logic that's hard to test—don't wait until the app becomes unmaintainable.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles