This episode covers Swift application architecture: a comparison of MVC, MVVM, MVP, and Clean Architecture, protocol-oriented design with dependency injection, the coordinator pattern for navigation, and state management using Combine and SwiftUI.

As an app grows from dozens to thousands of files, architecture determines whether changes stay easy or turn into a nightmare. Episode 16 covers architecture and design patterns in Swift: MVC, MVVM, MVP, Clean Architecture, plus supporting patterns like dependency injection, coordinator, and Combine.
No single architecture is right for every case. What you learn here are the principles that separate good architecture from bad: separation of concerns, controlled dependency direction, and easy testability.
MVC (Model-View-Controller) is UIKit's native architecture: the View displays data, the Controller mediates, and the Model holds data. Its classic problem is the Massive View Controller — the controller absorbs all logic because it becomes the only place to put code.
MVP (Model-View-Presenter) moves display logic into the Presenter, with the View only forwarding events and rendering results. The Presenter can be tested without a real UI — but the View must implement a protocol, which adds boilerplate.
MVVM (Model-View-ViewModel) separates state and display logic into a ViewModel that doesn't depend on the View. The View observes the ViewModel and reacts to its changes:
import Combine
import Foundation
final class LoginViewModel: ObservableObject {
@Published var email = ""
@Published var password = ""
@Published var pesanError: String?
var bisaSubmit: Bool {
!email.isEmpty && !password.isEmpty
}
}@Published var email = "" creates a property the View can observe, and var bisaSubmit provides derived state. A pure-Swift ViewModel can be tested without a UI — that's the main appeal of MVVM for SwiftUI apps.
Clean Architecture centers business rules in a core, separate from frameworks and interfaces. The outer layers (UI, database, networking) depend on the core, not the other way around. In Swift, the core is expressed with protocols and use cases, while frameworks live in the outermost layer.
Episode 5 introduced protocols; in architecture, protocols become design decision points. Dependencies point at abstractions, not implementations:
protocol PenyimpanCatatan {
func simpan(_ teks: String) throws
}
struct PenyimpanFile: PenyimpanCatatan {
func simpan(_ teks: String) throws {
let url = docs.appendingPathComponent("catatan.txt")
try teks.write(to: url, atomically: true, encoding: .utf8)
}
}
struct PenyimpanInMemory: PenyimpanCatatan {
var data: [String] = []
func simpan(_ teks: String) throws {
data.append(teks)
}
}protocol PenyimpanCatatan defines a storage contract. The ViewModel receives a PenyimpanCatatan when created — tests inject PenyimpanInMemory, production uses PenyimpanFile. This is dependency injection: dependencies are provided from outside, not created inside.
DI offers three real advantages:
A Coordinator moves navigation logic out of the View Controller, so the View Controller doesn't need to know which screen to go to next:
final class MainCoordinator {
private let navigator: UINavigationController
init(navigator: UINavigationController) {
self.navigator = navigator
}
func mulai() {
let vc = DaftarViewController(coordinator: self)
navigator.pushViewController(vc, animated: true)
}
func bukaDetail(id: Int) {
let vc = DetailViewController(id: id)
navigator.pushViewController(vc, animated: true)
}
}MainCoordinator manages transitions between screens in one place. The View Controller just calls coordinator methods — navigation becomes centralized, easy to test, and easy to change. In SwiftUI, this role is often replaced by explicitly defined route values.
Combine is a framework for processing streams of values that change over time. Its core is a Publisher that emits values and a Subscriber that receives them:
let publisher = Just("Halo Combine")
let cancellable = publisher.sink { nilai in
print(nilai)
}Just("Halo Combine") is a publisher that emits a single value and then completes. publisher.sink { ... } receives that value. Combine provides transformation operators like map, filter, and combineLatest that make app state flow declaratively.
SwiftUI integrates Combine natively. Views observe ObservableObjects through property wrappers:
import SwiftUI
struct LoginView: View {
@StateObject var viewModel = LoginViewModel()
var body: some View {
VStack {
TextField("Email", text: $viewModel.email)
SecureField("Password", text: $viewModel.password)
if let pesan = viewModel.pesanError {
Text(pesan)
}
}
}
}@StateObject var viewModel = LoginViewModel() makes the ViewModel owned by the view and keeps it alive across re-renders. Every change to an @Published property triggers a re-render of the view — this is the heart of SwiftUI state management, which we'll deepen in episode 17.
Info
The best architecture is the one that fits your context. Start simple with MVVM for small features, and adopt stricter patterns — Clean Architecture, coordinator, modularization — as the team and codebase grow. Never force architecture as dogma.
Key takeaways:
In the next episode, episode 17, we'll cover SwiftUI and declarative UI — the basics of views and layout, state management with @State, @Binding, @ObservedObject, and @EnvironmentObject, animations and transitions with adaptive UI, and integration between UIKit and SwiftUI. Your UI becomes declarative and alive!