iOS Architectural Patterns Deep Dive — MVC, MVVM, TCA, and Beyond

Every iOS app needs a structure. The framework gives you UIViewController / View, but it doesn’t tell you how to organize business logic, data flow, and navigation. The patterns below cover common options, from classic UIKit MVC to third-party libraries like TCA.

MVC (Model-View-Controller)

     ┌──────────┐
     │  Model   │
     └────▲─────┘
          │ updates
     ┌────┴─────┐     owns     ┌──────────┐
     │Controller│◄────────────►│   View   │
     └──────────┘              └──────────┘

MVC is the classic UIKit pattern, with UIViewController acting as the controller. The Model holds data, the View draws pixels, and the Controller mediates between them.

// Model
struct User { let name: String }

// Controller (most of the logic lives here)
final class ProfileViewController: UIViewController {
  var user: User?

  override func viewDidLoad() {
    super.viewDidLoad()
    title = user?.name
  }
}

The common failure mode is the Massive View Controller: network calls, data parsing, layout, animation, and navigation accumulate in one file. This makes testing hard and maintenance painful.

When to use MVC: Small projects, simple screens, or when you’re working on a legacy codebase that already uses it. Don’t start a new project with vanilla MVC if you expect significant complexity.

MVVM (Model-View-ViewModel)

┌──────────┐   observes   ┌──────────┐   updates    ┌──────────┐
│          │◄─────────────│          │─────────────►│          │
│   View   │              │ ViewModel│              │  Model   │
│          │─────────────►│          │              │          │
└──────────┘   action     └──────────┘              └──────────┘

MVVM addresses MVC’s flaws by extracting presentation logic into a ViewModel. The View observes the ViewModel’s state. Keeping the ViewModel independent of UIKit and SwiftUI makes it easier to test.

MVVM with Combine (UIKit)

import Combine
import Dispatch

@MainActor
final class ProfileViewModel {
  @Published private(set) var name: String = ""
  @Published private(set) var isLoading = false
  private let api: APIClient

  init(api: APIClient) {
    self.api = api
  }

  func load() {
    isLoading = true
    api.fetchUser()
      .map(\.name)
      .receive(on: DispatchQueue.main)
      .handleEvents(receiveCompletion: { [weak self] _ in self?.isLoading = false })
      .assign(to: &$name)
  }
}

// In UIViewController
viewModel.$name
  .receive(on: DispatchQueue.main)
  .sink { [weak self] name in self?.nameLabel.text = name }
  .store(in: &cancellables)

MVVM with Observation (SwiftUI)

With the @Observable macro (iOS 17+), SwiftUI-native MVVM needs less observation boilerplate:

import Observation

@MainActor
@Observable
final class ProfileViewModel {
  var name = ""
  var isLoading = false
  private let api: APIClient

  init(api: APIClient) {
    self.api = api
  }

  func load() async {
    isLoading = true
    name = await api.fetchUser().name
    isLoading = false
  }
}

struct ProfileView: View {
  @State private var viewModel: ProfileViewModel

  init(api: APIClient) {
    _viewModel = State(initialValue: ProfileViewModel(api: api))
  }

  var body: some View {
    Text(viewModel.name)
      .task { await viewModel.load() }
  }
}

No ObservableObject, no @Published — just plain properties on a class annotated with @Observable. SwiftUI updates the view when a property read by its body changes. @State still holds the ViewModel instance, but no per-property wrappers are needed.

When to use MVVM: A practical choice when screens need testable presentation logic. It works with both UIKit+Combine and SwiftUI+Observation, and you can instantiate a ViewModel without any UI framework.

TCA (The Composable Architecture)

  ┌──────┐ Action ┌───────┐ runs ┌─────────┐ returns ┌────────┐
  │ View │───────►│ Store │─────►│ Reducer │────────►│ Effect │
  └──▲───┘        └──┬─▲──┘      └─────────┘         └───┬────┘
     │ State         │ └─────────────────────────────────┘
     └───────────────┘              Action

TCA is a unidirectional architecture library by Point-Free. The View sends an Action to the Store, the Store runs the Reducer and effects, and state changes update the View. Keeping state mutations in reducers and returning effects explicitly makes behavior easier to test.

import ComposableArchitecture

@Reducer
struct ProfileFeature {
  @ObservableState
  struct State: Equatable {
    var name = ""
    var isLoading = false
  }

  enum Action {
    case loadButtonTapped
    case userResponse(Result<String, Error>)
  }

  @Dependency(\.apiClient) var api

  var body: some ReducerOf<Self> {
    Reduce { state, action in
      switch action {
      case .loadButtonTapped:
        state.isLoading = true
        return .run { send in
          do {
            let name = try await api.fetchUserName()
            await send(.userResponse(.success(name)))
          } catch {
            await send(.userResponse(.failure(error)))
          }
        }

      case let .userResponse(.success(name)):
        state.isLoading = false
        state.name = name
        return .none

      case .userResponse(.failure):
        state.isLoading = false
        return .none
      }
    }
  }
}

struct ProfileView: View {
  let store: StoreOf<ProfileFeature>

  var body: some View {
    Text(store.name)
    Button("Load") { store.send(.loadButtonTapped) }
  }
}

TCA is useful in large apps because state changes and effects are explicit, testable, and composable. The trade-off is additional structure and a steeper learning curve.

When to use TCA: Large, multi-feature apps where state consistency across screens is critical. Apps with complex side-effect chains (networking, timers, location, etc.). Teams that value deterministic testing.

VIPER

┌──────────┐   requests   ┌──────────┐   commands   ┌──────────┐
│          │─────────────►│          │─────────────►│          │
│   View   │              │Presenter │              │Interactor│
│          │◄─────────────│          │              │          │
└──────────┘   updates    └────┬─────┘              └──────────┘
                               │ routes
                               ▼
                          ┌──────────┐
                          │  Router  │
                          └──────────┘

VIPER was popularized around 2015-2018 as a reaction to massive view controllers. It splits responsibilities into five roles: View, Interactor, Presenter, Entity, Router.

// Entity — plain data
struct UserEntity: Codable {
  let name: String
}

// Interactor — business logic and data access
protocol ProfileInteracting: AnyObject {
  func loadUser() async throws -> UserEntity
}

final class ProfileInteractor: ProfileInteracting {
  private let api: APIClient

  init(api: APIClient) {
    self.api = api
  }

  func loadUser() async throws -> UserEntity {
    try await api.fetchUser()
  }
}

// Presenter — transforms data for the view
@MainActor
protocol ProfilePresenting: AnyObject {
  func viewDidLoad()
}

@MainActor
final class ProfilePresenter: ProfilePresenting {
  weak var view: ProfileViewable?
  private let interactor: ProfileInteracting
  private let router: ProfileRouting

  init(interactor: ProfileInteracting, router: ProfileRouting) {
    self.interactor = interactor
    self.router = router
  }

  func viewDidLoad() {
    Task {
      guard let entity = try? await interactor.loadUser() else { return }
      view?.display(name: entity.name)
    }
  }

  func didTapSettings() {
    router.navigateToSettings()
  }
}

// View — passive, only displays
@MainActor
protocol ProfileViewable: AnyObject {
  func display(name: String)
}

final class ProfileViewController: UIViewController, ProfileViewable {
  let presenter: ProfilePresenting

  init(presenter: ProfilePresenting) {
    self.presenter = presenter
    super.init(nibName: nil, bundle: nil)
  }

  required init?(coder: NSCoder) {
    fatalError("init(coder:) has not been implemented")
  }

  override func viewDidLoad() {
    super.viewDidLoad()
    presenter.viewDidLoad()
  }

  func display(name: String) {
    title = name
  }
}

// Router — handles navigation
@MainActor
protocol ProfileRouting: AnyObject {
  func navigateToSettings()
}

@MainActor
final class ProfileRouter: ProfileRouting {
  weak var viewController: UIViewController?

  func navigateToSettings() {
    let settingsVC = SettingsModuleBuilder.build()
    viewController?.navigationController?.pushViewController(settingsVC, animated: true)
  }
}

VIPER enforces strict single responsibility, but the ceremony is high: each screen often needs several types and protocols. For new work, compare that overhead with lighter MVVM or unidirectional state-management approaches.

When to use VIPER: Maintaining an existing VIPER codebase. Avoid for new projects.

Coordinators (Navigation Pattern)

AppCoordinator
  ├── ProfileCoordinator
  │     ├── ProfileVC
  │     └── SettingsCoordinator
  │           ├── SettingsVC
  │           └── PasswordVC
  └── AuthCoordinator
        ├── LoginVC
        └── SignupVC

Coordinators extract navigation logic from view controllers into dedicated objects. This pairs naturally with MVVM — the ViewModel signals an event, the Coordinator decides which screen to show.

@MainActor
protocol Coordinator: AnyObject {
  var childCoordinators: [Coordinator] { get set }
  func start()
}

@MainActor
final class AppCoordinator: Coordinator {
  var childCoordinators: [Coordinator] = []
  private let navigation: UINavigationController

  init(navigation: UINavigationController) {
    self.navigation = navigation
  }

  func start() {
    let viewController = ProfileModuleBuilder.build(
      onSettingsTapped: { [weak self] in self?.showSettings() }
    )
    navigation.pushViewController(viewController, animated: true)
  }

  private func showSettings() {
    let child = SettingsCoordinator(navigation: navigation)
    childCoordinators.append(child)
    child.start()
  }
}

With SwiftUI, NavigationStack and .navigationDestination reduce the need for coordinator objects, but a dedicated navigation model can still help with deep linking, conditional onboarding flows, and complex tab+modal navigation.

Clean Architecture

Clean Architecture (Robert C. Martin) organizes code into concentric layers with a strict dependency rule: inner layers never know about outer layers. Dependencies point inward, and the domain layer at the center has zero framework imports.

The Onion

                   Frameworks (UIKit, SwiftUI, Core Data)
                   ┌─────────────────────────────────────┐
                   │  Interface Adapters (ViewModels,    │
                   │  Presenters, Repositories)          │
                   │  ┌────────────────────────────────┐ │
                   │  │  Use Cases / Application       │ │
                   │  │  ┌───────────────────────────┐ │ │
                   │  │  │  Domain Entities          │ │ │
                   │  │  └───────────────────────────┘ │ │
                   │  └────────────────────────────────┘ │
                   └─────────────────────────────────────┘

Layer by layer with code

1. Domain Layer — pure Swift, no imports. Contains entities and use case protocols.

// Domain/User.swift
struct User {
  let id: String
  let name: String
}

// Domain/UserRepository.swift
protocol UserRepository {
  func fetchUser(id: String) async throws -> User
}

2. Use Cases Layer — orchestrates domain objects, depends only on domain protocols.

// Application/GetUserUseCase.swift
final class GetUserUseCase {
  private let repository: UserRepository

  init(repository: UserRepository) {
    self.repository = repository
  }

  func execute(id: String) async throws -> User {
    try await repository.fetchUser(id: id)
  }
}

3. Interface Adapters — adapt data between the application and external systems. This includes presentation models and repository implementations.

// Presentation/UserViewModel.swift
import Observation

@MainActor
@Observable
final class UserViewModel {
  private(set) var name = ""
  private let useCase: GetUserUseCase

  init(useCase: GetUserUseCase) {
    self.useCase = useCase
  }

  func loadUser(id: String) async {
    let user = try? await useCase.execute(id: id)
    name = user?.name ?? ""
  }
}
// Data/UserRepositoryImpl.swift
final class UserRepositoryImpl: UserRepository {
  private let api: APIClient  // framework type lives only in this layer

  init(api: APIClient) {
    self.api = api
  }

  func fetchUser(id: String) async throws -> User {
    let dto = try await api.request("/users/\(id)")
    return User(id: dto.id, name: dto.name)
  }
}

4. Frameworks Layer — UIKit, SwiftUI, Core Data, URLSession. Glues everything together via dependency injection.

// Composition Root
let repository = UserRepositoryImpl(api: URLSessionAPIClient())
let useCase = GetUserUseCase(repository: repository)
let viewModel = UserViewModel(useCase: useCase)
let view = UserView(viewModel: viewModel)

Why it matters

  • Domain has zero importsUser and UserRepository know nothing about UIKit, SwiftUI, Combine, or URLSession. You can test them in a playground or a unit test with no setup.
  • Dependency InversionUserRepository is a protocol in the Domain layer. The concrete implementation lives in the Data layer. The Domain never imports the Data layer.
  • Swappable infrastructure — swap URLSessionAPIClient for MockAPIClient without touching Use Cases or Domain. Swap Core Data for SwiftData behind the same repository protocol.

When to use Clean Architecture

Apply it when your app has significant business logic that you want to keep framework-independent and exhaustively testable. It is often unnecessary for a 2-screen app, but useful for complex domain rules shared across teams or platforms.

Which Pattern to Choose

Situation Recommendation
Small app, 1-2 screens MVC or lightweight MVVM. Don’t over-engineer.
Medium app, SwiftUI Start with SwiftUI state and Observation. Add MVVM when screens need testable presentation logic.
Medium app, UIKit MVVM with Combine. Use Coordinator for navigation.
Large app, many features Consider TCA for consistent state management, or MVVM + Coordinator with clear boundaries.
Complex business rules Add Clean Architecture layers where framework-independent domain logic pays off.
Existing VIPER codebase Keep VIPER in place, migrate new features to MVVM or TCA.
Single-purpose library/SDK Keep the public API focused. Avoid imposing an app-level architecture on dependents.

No architecture is a silver bullet. The goal is separation of concerns, testability, and maintainability. The best pattern is the one your team understands and applies consistently.