Architecture
MVC, MVVM, Clean Architecture, DI, and domain modeling.
PART 8 — ARCHITECTURE
Learning Objectives
By the end of this volume, you will understand how to structure professional iOS applications. You will be able to design scalable systems using MVVM, Feature-Based Architecture, Dependency Injection, Repositories, Services, Networking, Persistence, Domain Models, DTOs, State Architecture, and Modularization via Swift Package Manager (SPM).
Prerequisites
You must understand Swift value vs. reference semantics, protocols, generics, basic concurrency, and the fundamentals of SwiftUI and UIKit lifecycle.
Why Does This Exist?
As applications grow, putting all code in ViewControllers or SwiftUI Views leads to spaghetti code. We need architecture to separate concerns, make the code testable, maintainable, and scalable.
The Problem Before the Solution
Before robust architectures, developers dumped network calls, database queries, and UI updates into a single file (the infamous Massive View Controller). When a new feature was requested, modifying the file broke existing features.
Why the Old Approach Breaks
Tight coupling means you cannot test business logic without the UI. It ruins performance through main-thread blocking, ruins correctness through unintended state sharing, and destroys scalability because teams step on each other's toes.
History
Apple originally pushed MVC (Model-View-Controller). But controllers became too large. The community shifted to MVP, VIPER, and MVVM (Model-View-ViewModel). With SwiftUI, MVVM and state-driven architectures (like Redux/TCA) became the standard. Modern iOS development now heavily favors Modularization and Feature-Based Architecture.
Mental Model
Think of your app as a restaurant. The UI is the waiter (View), the ViewModel is the manager taking orders and coordinating, the Service Layer is the kitchen cooking the food, and the Database/Network are the suppliers delivering ingredients.
Now remove the analogy. Here is what Swift/iOS actually does: Views only observe state. ViewModels hold state and handle user intents. Services execute async work and return data. Repositories abstract where the data comes from.
Internal Working
State changes in the ViewModel (usually @Observable or ObservableObject) trigger SwiftUI's view invalidation. The view body is recomputed. Services run on background Tasks to avoid blocking the main thread. Memory is managed via ARC, requiring weak references in callbacks or structured concurrency to prevent leaks.
Visual Explanation
User Action
↓
View (SwiftUI)
↓
ViewModel (Intent / State update)
↓
Service / Repository (Business Logic)
↓
Network / CoreData (I/O)
↓
(Data Returns) -> ViewModel updates State -> View re-renders
Syntax
Defining a Service protocol and a ViewModel:
protocol UserService {
func fetchUser() async throws -> User
}
@MainActor
class UserViewModel: ObservableObject {
@Published var user: User?
private let service: UserService
init(service: UserService) {
self.service = service
}
}
Tiny Example
A simple modular architecture setup:
// Domain Model
struct User { let id: String; let name: String }
// DTO (Data Transfer Object)
struct UserDTO: Codable {
let id: String
let full_name: String
func mapToDomain() -> User { User(id: id, name: full_name) }
}
// Repository
class UserRepository {
func getUser() async throws -> User {
// Fetch DTO, map to Domain
return User(id: "1", name: "Alice")
}
}
Walkthrough
We decouple the data source (DTOs from the Network) from the app's internal representation (Domain Models). The Repository handles the mapping. The ViewModel asks the Repository for Domain Models. The View only knows about the ViewModel.
Break It
Let's intentionally create a retain cycle and block the main thread by injecting a service incorrectly and using unstructured concurrency without @MainActor.
class BadViewModel: ObservableObject {
var service = BadService()
init() {
service.onComplete = { self.updateUI() } // Retain cycle!
}
func updateUI() { /* UI update off main thread! */ }
}
Debug It
Use Xcode's Memory Graph Debugger to find the strong reference cycle between BadViewModel and BadService. Use the Main Thread Checker to catch UI updates occurring on a background queue. Fix it by using [weak self] in the closure or switching to modern Swift Concurrency (async/await and @MainActor).
How do we fix the retain cycle?
Use [weak self] or migrate to structured concurrency where closures aren't escaping in the same way.
View Solution
@MainActor
class GoodViewModel: ObservableObject {
private let service: GoodService
init(service: GoodService) {
self.service = service
}
func load() async {
let data = await service.fetch()
self.updateUI(with: data)
}
}
Mini Project (20-30 min)
Implement a basic MVVM pattern for a login screen. Separate the View (SwiftUI) from the ViewModel, keeping all validation logic out of the View.
View Solution
import SwiftUI
// ViewModel
class LoginViewModel: ObservableObject {
@Published var username = ""
@Published var password = ""
@Published var errorMessage: String? = nil
var isValid: Bool {
return username.count > 3 && password.count > 5
}
func login() {
guard isValid else {
errorMessage = "Invalid credentials"
return
}
// Mock network call
errorMessage = nil
print("Logging in user: \(username)")
}
}
// View
struct LoginView: View {
@StateObject private var viewModel = LoginViewModel()
var body: some View {
VStack(spacing: 20) {
TextField("Username", text: $viewModel.username)
.textFieldStyle(RoundedBorderTextFieldStyle())
SecureField("Password", text: $viewModel.password)
.textFieldStyle(RoundedBorderTextFieldStyle())
if let error = viewModel.errorMessage {
Text(error).foregroundColor(.red)
}
Button("Login") {
viewModel.login()
}
.disabled(!viewModel.isValid)
.buttonStyle(.borderedProminent)
}
.padding()
}
}
Bigger Project (1-2 hours)
Build a mini application using Clean Architecture (Presentation, Domain, and Data layers). Create a Use Case that fetches a list of movies from a Repository protocol. The UI should use a ViewModel to trigger the Use Case.
View Solution
import SwiftUI
// 1. Domain Layer (Entities & Protocols)
struct Movie: Identifiable {
let id: UUID
let title: String
}
protocol MovieRepository {
func getMovies() async throws -> [Movie]
}
class GetMoviesUseCase {
let repository: MovieRepository
init(repository: MovieRepository) { self.repository = repository }
func execute() async throws -> [Movie] { return try await repository.getMovies() }
}
// 2. Data Layer
class MockMovieRepository: MovieRepository {
func getMovies() async throws -> [Movie] {
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second delay
return [Movie(id: UUID(), title: "Inception"), Movie(id: UUID(), title: "Interstellar")]
}
}
// 3. Presentation Layer
@MainActor
class MovieListViewModel: ObservableObject {
@Published var movies: [Movie] = []
private let getMoviesUseCase: GetMoviesUseCase
init(useCase: GetMoviesUseCase) {
self.getMoviesUseCase = useCase
}
func load() async {
do {
self.movies = try await getMoviesUseCase.execute()
} catch {
print("Failed to load")
}
}
}
struct MovieListView: View {
@StateObject var viewModel: MovieListViewModel
var body: some View {
List(viewModel.movies) { movie in
Text(movie.title)
}
.task {
await viewModel.load()
}
}
}
Interview Questions
Easy: What is the main benefit of the MVVM architecture over MVC in iOS?
It prevents the "Massive View Controller" problem by moving business logic, formatting, and state management out of the UI layer (ViewController/View) into a highly testable ViewModel.
Medium: What is dependency injection and why is it important?
Dependency injection is the practice of passing dependencies (like network services or databases) into an object rather than letting the object create them. It is important because it decouples objects, making them highly reusable and easily testable using mocks.
Hard: How does the Dependency Inversion Principle (the 'D' in SOLID) apply to Clean Architecture?
The principle states that high-level modules should not depend on low-level modules; both should depend on abstractions (protocols). In Clean Architecture, the Domain layer dictates the protocol (e.g., `UserRepository`). The Data layer implements it. The Domain layer doesn't know about the Data layer's concrete implementation, reversing the traditional dependency direction and isolating business rules from frameworks.