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.
┌──────────┐
│ 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.
┌──────────┐ 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.
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)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.
┌──────┐ Action ┌───────┐ runs ┌─────────┐ returns ┌────────┐
│ View │───────►│ Store │─────►│ Reducer │────────►│ Effect │
└──▲───┘ └──┬─▲──┘ └─────────┘ └───┬────┘
│ State │ └─────────────────────────────────┘
└───────────────┘ ActionTCA 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.
┌──────────┐ 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.
AppCoordinator
├── ProfileCoordinator
│ ├── ProfileVC
│ └── SettingsCoordinator
│ ├── SettingsVC
│ └── PasswordVC
└── AuthCoordinator
├── LoginVC
└── SignupVCCoordinators 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 (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.
Frameworks (UIKit, SwiftUI, Core Data)
┌─────────────────────────────────────┐
│ Interface Adapters (ViewModels, │
│ Presenters, Repositories) │
│ ┌────────────────────────────────┐ │
│ │ Use Cases / Application │ │
│ │ ┌───────────────────────────┐ │ │
│ │ │ Domain Entities │ │ │
│ │ └───────────────────────────┘ │ │
│ └────────────────────────────────┘ │
└─────────────────────────────────────┘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)User and UserRepository know nothing about UIKit, SwiftUI, Combine, or URLSession. You can test them in a playground or a unit test with no setup.UserRepository is a protocol in the Domain layer. The concrete implementation lives in the Data layer. The Domain never imports the Data layer.URLSessionAPIClient for MockAPIClient without touching Use Cases or Domain. Swap Core Data for SwiftData behind the same repository protocol.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.
| 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.