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:
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:
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:
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.






Comments
Loading comments…