Skip to main content

Swift Architectural Patterns

Introduction

When building Swift applications, choosing the right architectural pattern is as important as writing clean code. Architectural patterns provide structure to your codebase, making it easier to maintain, test, and scale your applications. For beginners entering the Swift ecosystem, understanding these patterns will set a solid foundation for your development journey.

This guide will explore the most common architectural patterns used in Swift development, their advantages and disadvantages, and practical examples to help you understand when and how to use them.

Why Do We Need Architectural Patterns?

Before diving into specific patterns, let's understand why they matter:

  • Organized Code: Patterns separate concerns and give each component a clear responsibility
  • Testability: Well-structured code is easier to test
  • Maintainability: Makes it easier to fix bugs and add features
  • Collaboration: Enables multiple developers to work on the same project efficiently
  • Scalability: Allows your application to grow without becoming a mess

Model-View-Controller (MVC)

What is MVC?

MVC is the traditional architectural pattern promoted by Apple. It divides your application into three interconnected components:

  • Model: The data and business logic
  • View: The user interface elements
  • Controller: The mediator between Model and View

How MVC Works in Swift

swift
// Model
struct User {
let id: Int
let name: String
let email: String
}

// View (typically a UIView or UIViewController subclass in Interface Builder)
class UserProfileView: UIView {
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var emailLabel: UILabel!

func configure(with user: User) {
nameLabel.text = user.name
emailLabel.text = user.email
}
}

// Controller
class UserProfileViewController: UIViewController {
@IBOutlet weak var userProfileView: UserProfileView!
var user: User?

override func viewDidLoad() {
super.viewDidLoad()

if let user = user {
userProfileView.configure(with: user)
}
}

func fetchUserData() {
// Call API to fetch user data
// On completion, update the model and view
let user = User(id: 1, name: "John Doe", email: "[email protected]")
self.user = user
userProfileView.configure(with: user)
}
}

Advantages of MVC

  • Simple to understand and implement
  • Officially supported by Apple
  • Perfect for small applications
  • Minimal setup required

Disadvantages of MVC

  • Controllers often become massive ("Massive View Controller" problem)
  • Difficult to test due to tight coupling
  • View and Controller are often too interconnected

Model-View-ViewModel (MVVM)

What is MVVM?

MVVM improves upon MVC by introducing a ViewModel layer that handles the presentation logic:

  • Model: Data and business logic (same as MVC)
  • View: User interface elements (same as MVC)
  • ViewModel: Transforms model data for the view and handles view logic

How MVVM Works in Swift

swift
// Model
struct User {
let id: Int
let name: String
let email: String
}

// ViewModel
class UserProfileViewModel {
private let user: User

init(user: User) {
self.user = user
}

var displayName: String {
return user.name
}

var displayEmail: String {
return user.email
}
}

// View/Controller
class UserProfileViewController: UIViewController {
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var emailLabel: UILabel!

var viewModel: UserProfileViewModel!

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

func updateUI() {
nameLabel.text = viewModel.displayName
emailLabel.text = viewModel.displayEmail
}
}

Adding Bindings with Combine

Modern MVVM implementations often use reactive frameworks. Here's how you can enhance the above example with Combine:

swift
// ViewModel with Combine
import Combine

class UserProfileViewModel {
private let user: User

// Published properties automatically announce changes
@Published var displayName: String
@Published var displayEmail: String

init(user: User) {
self.user = user
self.displayName = user.name
self.displayEmail = user.email
}

func updateUser(_ user: User) {
displayName = user.name
displayEmail = user.email
}
}

// View/Controller with Combine
import Combine

class UserProfileViewController: UIViewController {
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var emailLabel: UILabel!

var viewModel: UserProfileViewModel!
private var cancellables = Set<AnyCancellable>()

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

func setupBindings() {
// Bind ViewModel properties to UI
viewModel.$displayName
.receive(on: RunLoop.main)
.sink { [weak self] name in
self?.nameLabel.text = name
}
.store(in: &cancellables)

viewModel.$displayEmail
.receive(on: RunLoop.main)
.sink { [weak self] email in
self?.emailLabel.text = email
}
.store(in: &cancellables)
}
}

Advantages of MVVM

  • Better separation of concerns
  • More testable components (especially the ViewModel)
  • Reduces the responsibility of the View/Controller
  • Works well with reactive programming (Combine, RxSwift)

Disadvantages of MVVM

  • More complex than MVC
  • Requires more boilerplate code
  • Can be overengineered for simple applications

VIPER

What is VIPER?

VIPER is a more advanced architectural pattern that stands for:

  • View: Displays information and sends user actions to the Presenter
  • Interactor: Contains business logic
  • Presenter: Contains view logic and prepares data for presentation
  • Entity: Basic model objects
  • Router: Handles navigation logic between screens

How VIPER Works in Swift

Let's implement a simple user profile screen using VIPER:

swift
// Entity
struct User {
let id: Int
let name: String
let email: String
}

// Presenter Protocol
protocol UserProfilePresenterProtocol {
func viewDidLoad()
func editButtonTapped()
}

// View Protocol
protocol UserProfileViewProtocol: AnyObject {
func displayUserProfile(name: String, email: String)
func showLoading()
func hideLoading()
}

// Interactor Protocol
protocol UserProfileInteractorProtocol {
func fetchUserProfile()
}

// Router Protocol
protocol UserProfileRouterProtocol {
func navigateToEditProfile()
}

// Interactor Implementation
class UserProfileInteractor: UserProfileInteractorProtocol {
weak var presenter: UserProfilePresenterOutputProtocol?

func fetchUserProfile() {
// Simulate API call
DispatchQueue.global().async {
// Fetch user data
let user = User(id: 1, name: "John Doe", email: "[email protected]")

DispatchQueue.main.async {
self.presenter?.didFetchUserProfile(user)
}
}
}
}

// Presenter Output Protocol
protocol UserProfilePresenterOutputProtocol: AnyObject {
func didFetchUserProfile(_ user: User)
}

// Presenter Implementation
class UserProfilePresenter: UserProfilePresenterProtocol, UserProfilePresenterOutputProtocol {
weak var view: UserProfileViewProtocol?
var interactor: UserProfileInteractorProtocol
var router: UserProfileRouterProtocol

init(view: UserProfileViewProtocol, interactor: UserProfileInteractorProtocol, router: UserProfileRouterProtocol) {
self.view = view
self.interactor = interactor
self.router = router
}

func viewDidLoad() {
view?.showLoading()
interactor.fetchUserProfile()
}

func editButtonTapped() {
router.navigateToEditProfile()
}

// MARK: - UserProfilePresenterOutputProtocol
func didFetchUserProfile(_ user: User) {
view?.hideLoading()
view?.displayUserProfile(name: user.name, email: user.email)
}
}

// View Implementation
class UserProfileViewController: UIViewController, UserProfileViewProtocol {
var presenter: UserProfilePresenterProtocol!

@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var emailLabel: UILabel!
@IBOutlet weak var loadingIndicator: UIActivityIndicatorView!

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

@IBAction func editButtonTapped(_ sender: UIButton) {
presenter.editButtonTapped()
}

// MARK: - UserProfileViewProtocol
func displayUserProfile(name: String, email: String) {
nameLabel.text = name
emailLabel.text = email
}

func showLoading() {
loadingIndicator.startAnimating()
}

func hideLoading() {
loadingIndicator.stopAnimating()
}
}

// Router Implementation
class UserProfileRouter: UserProfileRouterProtocol {
weak var viewController: UIViewController?

init(viewController: UIViewController) {
self.viewController = viewController
}

func navigateToEditProfile() {
// Navigate to edit profile screen
let editProfileVC = UIStoryboard(name: "Main", bundle: nil)
.instantiateViewController(withIdentifier: "EditProfileViewController")
viewController?.navigationController?.pushViewController(editProfileVC, animated: true)
}
}

// Module Builder
class UserProfileModuleBuilder {
static func build() -> UserProfileViewController {
let view = UIStoryboard(name: "Main", bundle: nil)
.instantiateViewController(withIdentifier: "UserProfileViewController") as! UserProfileViewController
let interactor = UserProfileInteractor()
let router = UserProfileRouter(viewController: view)
let presenter = UserProfilePresenter(view: view, interactor: interactor, router: router)

view.presenter = presenter
interactor.presenter = presenter

return view
}
}

Advantages of VIPER

  • Highly testable architecture
  • Clear separation of concerns
  • Well-defined responsibilities for each component
  • Scales well for large applications

Disadvantages of VIPER

  • Significant boilerplate code required
  • Steep learning curve for beginners
  • Can be excessive for simple applications

Choosing the Right Pattern

Here's a simple guide to help you choose:

  1. For simple apps or learning: Start with MVC
  2. For medium-sized apps: Consider MVVM
  3. For complex, large-scale apps: Consider VIPER or other advanced patterns

Real-World Example: Todo App

Let's implement a simple Todo app with MVVM to solidify our understanding:

swift
// Model
struct Todo {
let id: UUID
var title: String
var isCompleted: Bool
}

// ViewModel
class TodoListViewModel {
@Published private(set) var todos: [Todo] = []

func addTodo(title: String) {
let newTodo = Todo(id: UUID(), title: title, isCompleted: false)
todos.append(newTodo)
}

func toggleCompletion(at index: Int) {
guard index < todos.count else { return }
todos[index].isCompleted.toggle()
}

func removeTodo(at index: Int) {
guard index < todos.count else { return }
todos.remove(at: index)
}
}

// View/Controller
import Combine

class TodoListViewController: UIViewController, UITableViewDataSource, UITableViewDelegate {
@IBOutlet weak var tableView: UITableView!
@IBOutlet weak var newTodoTextField: UITextField!

private let viewModel = TodoListViewModel()
private var cancellables = Set<AnyCancellable>()

override func viewDidLoad() {
super.viewDidLoad()
setupBindings()
setupTableView()
}

private func setupBindings() {
viewModel.$todos
.receive(on: RunLoop.main)
.sink { [weak self] _ in
self?.tableView.reloadData()
}
.store(in: &cancellables)
}

private func setupTableView() {
tableView.dataSource = self
tableView.delegate = self
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "TodoCell")
}

@IBAction func addTodoButtonTapped(_ sender: UIButton) {
guard let todoText = newTodoTextField.text, !todoText.isEmpty else { return }
viewModel.addTodo(title: todoText)
newTodoTextField.text = ""
}

// UITableViewDataSource
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return viewModel.todos.count
}

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "TodoCell", for: indexPath)
let todo = viewModel.todos[indexPath.row]

var content = cell.defaultContentConfiguration()
content.text = todo.title
cell.contentConfiguration = content

cell.accessoryType = todo.isCompleted ? .checkmark : .none
return cell
}

// UITableViewDelegate
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
viewModel.toggleCompletion(at: indexPath.row)
}

func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
if editingStyle == .delete {
viewModel.removeTodo(at: indexPath.row)
}
}
}

This example demonstrates:

  • Clear separation between View and ViewModel
  • The ViewModel has no UIKit dependencies
  • Data binding with Combine
  • The View responds to model updates automatically

Summary

Architectural patterns are essential tools for building maintainable Swift applications. In this guide, we explored:

  • MVC: Simple but can lead to massive view controllers
  • MVVM: Better separation with a dedicated ViewModel layer
  • VIPER: Advanced pattern with highly separated components

Remember, there's no one-size-fits-all solution. Each project may require a different approach or even a hybrid of these patterns. Start simple and refactor as your application grows in complexity.

Additional Resources

Practice Exercises

  1. Convert MVC to MVVM: Take an existing MVC app and refactor it to use MVVM
  2. Build a Small App: Create a simple weather app using MVVM and Combine
  3. Implement VIPER: Build a simple multi-screen app using the VIPER pattern
  4. Test Your Architecture: Write unit tests for each component in your chosen architecture

Choose the right architecture for your needs, and remember that the ultimate goal is to create code that is maintainable, testable, and scalable!



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)