Skip to main content

Swift Design Patterns

Introduction

Design patterns are reusable solutions to common problems that arise during software development. They represent best practices evolved over time by experienced developers. In Swift and iOS development, understanding these patterns can help you write more maintainable, flexible, and robust code.

This guide will introduce you to several essential design patterns in Swift, explain when and why you should use them, and provide practical examples of each.

Why Design Patterns Matter

Before diving into specific patterns, it's important to understand their benefits:

  • They provide proven solutions to common problems
  • They make your code more reusable and maintainable
  • They establish a common vocabulary among developers
  • They help you write code that's easier to understand and debug
  • They encourage best practices and principles like SOLID

Common Design Patterns in Swift

Let's explore some of the most commonly used design patterns in Swift development.

1. MVC (Model-View-Controller)

MVC is the foundation design pattern for iOS development and separates your application into three main components:

  • Model: The data and business logic
  • View: The user interface elements
  • Controller: The mediator that connects the model and view

Example Implementation

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

// View
class UserProfileView: UIView {
let nameLabel = UILabel()
let emailLabel = UILabel()

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

// Controller
class UserProfileViewController: UIViewController {
let userProfileView = UserProfileView()
var user: User?

override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(userProfileView)

// Fetch user data and update view
if let user = user {
userProfileView.configure(with: user)
}
}
}

MVC is simple to understand but can lead to "Massive View Controllers" where too much logic ends up in the controller. This has led to the popularity of alternative patterns like MVVM.

2. MVVM (Model-View-ViewModel)

MVVM adds a ViewModel layer that handles the presentation logic:

  • Model: The data and business logic
  • View: The user interface (includes UIViewControllers in iOS)
  • ViewModel: Transforms model information into values that can be displayed by the view

Example Implementation

swift
// Model
struct User {
let firstName: String
let lastName: String
let email: String
}

// ViewModel
class UserViewModel {
private let user: User

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

var fullName: String {
return "\(user.firstName) \(user.lastName)"
}

var email: String {
return user.email
}
}

// View (ViewController)
class ProfileViewController: UIViewController {
@IBOutlet weak var nameLabel: UILabel!
@IBOutlet weak var emailLabel: UILabel!

var viewModel: UserViewModel!

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

func updateUI() {
nameLabel.text = viewModel.fullName
emailLabel.text = viewModel.email
}
}

MVVM works especially well with reactive programming frameworks like Combine or RxSwift, where the view can automatically update when the ViewModel changes.

3. Singleton Pattern

The Singleton pattern ensures that a class has only one instance and provides a global point of access to it. It's commonly used for services, managers, or resources that should be shared across the app.

Example Implementation

swift
class NetworkManager {
// Shared instance
static let shared = NetworkManager()

// Private initializer prevents external instantiation
private init() {}

func fetchData(from url: URL, completion: @escaping (Data?, Error?) -> Void) {
URLSession.shared.dataTask(with: url) { data, response, error in
completion(data, error)
}.resume()
}
}

// Usage
let url = URL(string: "https://api.example.com/data")!
NetworkManager.shared.fetchData(from: url) { data, error in
if let data = data {
// Process the data
print("Received \(data.count) bytes")
} else if let error = error {
print("Error: \(error.localizedDescription)")
}
}

Note: While convenient, singletons should be used sparingly as they can make your code harder to test and maintain. Consider dependency injection as an alternative.

4. Factory Pattern

The Factory pattern provides an interface for creating objects without specifying their concrete classes. This is useful when object creation logic is complex or when you need to create different objects based on conditions.

Example Implementation

swift
// Protocol for all payment methods
protocol PaymentMethod {
func processPayment(amount: Double)
}

// Concrete implementations
class CreditCardPayment: PaymentMethod {
func processPayment(amount: Double) {
print("Processing credit card payment of $\(amount)")
}
}

class PayPalPayment: PaymentMethod {
func processPayment(amount: Double) {
print("Processing PayPal payment of $\(amount)")
}
}

class ApplePayPayment: PaymentMethod {
func processPayment(amount: Double) {
print("Processing Apple Pay payment of $\(amount)")
}
}

// Factory class
class PaymentFactory {
enum PaymentType {
case creditCard, payPal, applePay
}

static func createPayment(of type: PaymentType) -> PaymentMethod {
switch type {
case .creditCard:
return CreditCardPayment()
case .payPal:
return PayPalPayment()
case .applePay:
return ApplePayPayment()
}
}
}

// Usage
let paymentMethod = PaymentFactory.createPayment(of: .applePay)
paymentMethod.processPayment(amount: 99.99)
// Output: Processing Apple Pay payment of $99.99

The Factory pattern makes the code more maintainable and extensible when new payment methods need to be added.

5. Observer Pattern

The Observer pattern defines a one-to-many dependency between objects, so that when one object changes state, all its dependents are notified and updated automatically. In Swift, this can be implemented using various approaches.

Example with NotificationCenter

swift
// Define notification name
extension Notification.Name {
static let userDidUpdate = Notification.Name("userDidUpdate")
}

// Publisher
class UserManager {
var currentUser: User? {
didSet {
// Post notification when user changes
NotificationCenter.default.post(name: .userDidUpdate, object: currentUser)
}
}
}

// Observer
class ProfileViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()

// Register for notifications
NotificationCenter.default.addObserver(
self,
selector: #selector(userDidUpdate),
name: .userDidUpdate,
object: nil
)
}

@objc func userDidUpdate(notification: Notification) {
if let user = notification.object as? User {
// Update UI with user data
print("User updated: \(user.name)")
}
}

deinit {
NotificationCenter.default.removeObserver(self)
}
}

Example with Combine (iOS 13+)

swift
import Combine

class UserManager {
// Subject to publish updates
let userPublisher = PassthroughSubject<User, Never>()

func updateUser(_ user: User) {
userPublisher.send(user)
}
}

class ProfileViewController: UIViewController {
private var cancellables = Set<AnyCancellable>()
let userManager = UserManager()

override func viewDidLoad() {
super.viewDidLoad()

// Subscribe to updates
userManager.userPublisher
.sink { [weak self] user in
// Update UI with user data
print("User updated: \(user.name)")
}
.store(in: &cancellables)
}
}

The Observer pattern is particularly useful in iOS development for maintaining synchronized state across components.

6. Delegate Pattern

The Delegate pattern enables one object to send messages to another object when a specific event happens. It's widely used in iOS development, especially with UIKit components.

Example Implementation

swift
// Define the delegate protocol
protocol TaskCompletionDelegate: AnyObject {
func taskCompleted(result: String)
func taskFailed(error: Error)
}

// Class that uses the delegate
class TaskManager {
weak var delegate: TaskCompletionDelegate?

func performTask() {
// Simulate some work
DispatchQueue.global().async {
// Simulate success
if Bool.random() {
DispatchQueue.main.async {
self.delegate?.taskCompleted(result: "Task successful")
}
} else {
// Simulate failure
DispatchQueue.main.async {
let error = NSError(domain: "TaskError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Task failed"])
self.delegate?.taskFailed(error: error)
}
}
}
}
}

// Class that implements the delegate
class TaskViewController: UIViewController, TaskCompletionDelegate {
let taskManager = TaskManager()

override func viewDidLoad() {
super.viewDidLoad()
taskManager.delegate = self
taskManager.performTask()
}

func taskCompleted(result: String) {
print("Task completed: \(result)")
}

func taskFailed(error: Error) {
print("Task failed: \(error.localizedDescription)")
}
}

The delegate pattern provides a cleaner, more decoupled way for objects to communicate compared to direct method calls.

Real-World Application: Combining Patterns

In real-world applications, you'll often use multiple design patterns together. Let's see how we might structure a simple weather app:

swift
// Model
struct WeatherData: Codable {
let temperature: Double
let humidity: Int
let description: String
}

// ViewModel
class WeatherViewModel {
private(set) var weatherData: WeatherData?
let weatherDataPublisher = PassthroughSubject<WeatherData, Error>()

func fetchWeather(for city: String) {
WeatherService.shared.fetchWeather(for: city) { [weak self] result in
switch result {
case .success(let data):
self?.weatherData = data
self?.weatherDataPublisher.send(data)
case .failure(let error):
self?.weatherDataPublisher.send(completion: .failure(error))
}
}
}

var formattedTemperature: String {
guard let temp = weatherData?.temperature else { return "N/A" }
return "\(Int(temp))°C"
}

var formattedHumidity: String {
guard let humidity = weatherData?.humidity else { return "N/A" }
return "\(humidity)%"
}

var weatherDescription: String {
return weatherData?.description ?? "Unknown"
}
}

// Singleton Service
class WeatherService {
static let shared = WeatherService()

private init() {}

func fetchWeather(for city: String, completion: @escaping (Result<WeatherData, Error>) -> Void) {
// Simulate network request
DispatchQueue.global().async {
// In a real app, you would make an API call here
let mockData = WeatherData(
temperature: Double.random(in: 0...30),
humidity: Int.random(in: 30...90),
description: ["Sunny", "Cloudy", "Rainy", "Windy"].randomElement()!
)

DispatchQueue.main.async {
completion(.success(mockData))
}
}
}
}

// View Controller (combines MVVM and Observer patterns)
import UIKit
import Combine

class WeatherViewController: UIViewController {
@IBOutlet weak var cityTextField: UITextField!
@IBOutlet weak var temperatureLabel: UILabel!
@IBOutlet weak var humidityLabel: UILabel!
@IBOutlet weak var descriptionLabel: UILabel!

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

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

func setupBindings() {
viewModel.weatherDataPublisher
.receive(on: DispatchQueue.main)
.sink { completion in
if case let .failure(error) = completion {
print("Error: \(error.localizedDescription)")
}
} receiveValue: { [weak self] _ in
self?.updateUI()
}
.store(in: &cancellables)
}

@IBAction func searchButtonTapped(_ sender: UIButton) {
guard let city = cityTextField.text, !city.isEmpty else { return }
viewModel.fetchWeather(for: city)
}

private func updateUI() {
temperatureLabel.text = viewModel.formattedTemperature
humidityLabel.text = viewModel.formattedHumidity
descriptionLabel.text = viewModel.weatherDescription
}
}

This example combines:

  • MVVM pattern for separating presentation logic
  • Singleton pattern for the shared service
  • Observer pattern (using Combine) for reactive updates
  • Delegation pattern (implicitly through text field's delegate)

Summary

Design patterns are powerful tools that can significantly improve your Swift code. We've covered several essential patterns:

  1. MVC: The traditional pattern for iOS development
  2. MVVM: A more testable alternative to MVC that separates presentation logic
  3. Singleton: Ensures a class has only one instance with global access
  4. Factory: Creates objects without exposing creation logic
  5. Observer: Notifies objects of state changes
  6. Delegate: Enables objects to communicate through defined protocols

Remember that design patterns are guidelines, not strict rules. Choose the patterns that best solve your specific problems and don't be afraid to adapt them to your needs.

Additional Resources

To deepen your understanding of design patterns in Swift:

  1. Ray Wenderlich's Design Patterns in Swift
  2. Apple's Human Interface Guidelines for understanding patterns in the context of iOS design
  3. Swift by Sundell articles on design patterns

Exercises

  1. Practice MVC: Create a simple todo app using the MVC pattern
  2. Implement MVVM: Convert an existing view controller to use MVVM architecture
  3. Create a Factory: Build a UI component factory that can create different styles of buttons or labels
  4. Combine Patterns: Design an app feature that uses at least three different design patterns working together

By practicing these patterns in your projects, you'll gain a better understanding of when and how to apply them effectively.



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