Design Patterns by example on iOS

Peña Fernando
6 min readFeb 15, 2023

--

Photo by Laura Adai on Unsplash

Hi folks! Welcome to my blog, this time I will explain how to add functionalities and features by applying well known design patters to avoid component coupling. Moreover in the next post, I will go a step further and explain how to replace those Design Patterns with Universal Abstractions using Combine framework.

Random users sample project

You can download the initial project here.

It’s a simple project that loads a list of random users from a service and displays it in a list. We are using SwiftUI and MVVM on the presentation layer and the Adapter design pattern to decouple the api client as I’ve shown on a previous post.

Initial project components relationships diagram

The RandomUsersApiClientAdapter lives under the Composition Root, on the main App. This way we can develop, test and maintain the different components independently.

@main
struct RandomUsersApp: App {
var body: some Scene {
WindowGroup {
makeUsersView()
}
}
}

extension RandomUsersApp {
func makeUsersView() -> UsersView {
let usersRepository: UsersRepository = RandomUserAPIClient()
let viewModel = UsersViewModel(usersRespository: usersRepository)
return UsersView(viewModel: viewModel)
}
}

We just inject the adapted version of our api client when we compose the app. We are adapting RandomUserAPIClient by using an extension.

extension RandomUserAPIClient: UsersRepository {
func fetchUsers(completion: @escaping Completion) {
Task {
do {
let usersDto = try await fetchRandomUsers()
let users = usersDto.map(User.init(dto:))
completion(.success(users))
} catch {
completion(.failure(error))
}
}
}
}

Let’s take a closer look at our current UsersViewModel

class UsersViewModel: ObservableObject {
@Published var users: [User] = []
@Published var isLoading: Bool = false

private let usersRespository: UsersRepository

init(usersRespository: UsersRepository) {
self.usersRespository = usersRespository
}

func fetchUsers() {
isLoading = true
usersRespository.fetchUsers { result in
DispatchQueue.main.async {
self.isLoading = false
switch result {
case .success(let users):
self.users = users
case .failure:
break
}
}
}
}
}

In the fetchUsers method it’s loading the data from a usersRepository and updating the users @Published property with the response in the main thread. We’re not handling the error, nothing happens if it fails.

MainQueueDispatchDecorator

We can remove the responsibility of dispatching on the main thread from the ViewModel and send it to the repository, that way the ViewModel will be cleaner, no threading logic inside.

We use the Decorator design pattern to add behavior to a component without changing its implementation. We create the MainQueueDispatchDecorator that dispatches a closure on the main thread.

final class MainQueueDispatchDecorator<T> {
private let decoratee: T

init(decoratee: T) {
self.decoratee = decoratee
}

func dispatch(completion: @escaping () -> Void) {
DispatchQueue.main.async(execute: completion)
}
}

extension MainQueueDispatchDecorator: UsersRepository where T == UsersRepository {
func fetchUsers(completion: @escaping Completion) {
decoratee.fetchUsers { [weak self] result in
self?.dispatch { completion(result) }
}
}
}

And then we compose the App by decorating our previous UsersRepository

func makeUsersView() -> UsersView {
let usersRepository: UsersRepository = MainQueueDispatchDecorator(decoratee: RandomUserAPIClient())
let viewModel = UsersViewModel(usersRespository: usersRepository)
return UsersView(viewModel: viewModel)
}

This way we can remove the threading part from the ViewModel fetchUsers method:

func fetchUsers() {
isLoading = true
usersRespository.fetchUsers { result in
self.isLoading = false
switch result {
case .success(let users):
self.users = users
case .failure:
break
}
}
}

LoggerDecorator

Now new requirements came and we need to log every time an error occurs while fetching the users. You could use any error tracking platform for that like Firebase Crashlytics and apply it directly on the ViewModel.

case .failure(let error):
Crashlytics.crashlytics().log("Error: \(error)")

But this way we would be coupling our logging infrastructure with our Presentation layer, if we want to reuse our ViewModel on another module we would be forced to import our Crashlytics dependency too.

To decouple them we can use the Decorator pattern again. We created the LoggerProtocol to abstract the actual Logger implementation, in this example we are just using a local logger that just prints the logs on the console but it can be easily replace with a different implementation if we wanted to.

final class LoggerDecorator<T> {
private let decoratee: T
private let logger: LoggerProtocol

init(decoratee: T,
logger: LoggerProtocol = LocalLogger()) {
self.decoratee = decoratee
self.logger = logger
}
}

extension LoggerDecorator: UsersRepository where T == UsersRepository {
func fetchUsers(completion: @escaping Completion) {
decoratee.fetchUsers { [weak self] result in
switch result {
case .success: break
case .failure(let error): self?.logger.log("ERROR: \(error)")
}
completion(result)
}
}
}

protocol LoggerProtocol {
func log(_ items: Any..., separator: String, terminator: String)
}

final class LocalLogger: LoggerProtocol {
func log(_ items: Any..., separator: String = " ", terminator: String = "\n") {
#if DEBUG
let output = items.map { "\($0)" }.joined(separator: separator)
Swift.print(output, terminator: terminator)
#endif
}
}

We can now add the logging behavior without modifying our Presentation layer by changing the composition

func makeUsersView() -> UsersView {
let usersRepository: UsersRepository = LoggerDecorator(decoratee: MainQueueDispatchDecorator(decoratee: RandomUserAPIClient()))
let viewModel = UsersViewModel(usersRespository: usersRepository)
return UsersView(viewModel: viewModel)
}

BiometryDecorator

Another requirement came and we now need to authenticate the user using the device Face ID authentication system before we can consume get the data.

I won’t explain the benefits of decoupling the system again, to apply this new behavior we will use the Decorator pattern again.

final class BiometryDecorator<T> {
private let decoratee: T
private let authenticator: AuthenticatorProtocol

init(decoratee: T,
authenticator: AuthenticatorProtocol = LABiometryAuthenticator()) {
self.decoratee = decoratee
self.authenticator = authenticator
}
}

extension BiometryDecorator: UsersRepository where T == UsersRepository {
func fetchUsers(completion: @escaping Completion) {
Task {
do {
try await authenticator.authenticate()
decoratee.fetchUsers(completion: completion)
} catch {
completion(.failure(error))
}
}
}
}

protocol AuthenticatorProtocol {
func authenticate() async throws -> Void
}

final class LABiometryAuthenticator: AuthenticatorProtocol {
private let context: LAContext
private let localizedReason: String

init(context: LAContext = LAContext(),
localizedReason: String = "Biometry") {
self.context = context
self.localizedReason = localizedReason
}

func authenticate() async throws -> Void {
let result = try await LAContext().evaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, localizedReason: localizedReason)
if result == false {
throw BiometryError.authenticationFailed
}
}
}

And we finally compose it

func makeUsersView() -> UsersView {
let usersRepository: UsersRepository = LoggerDecorator(decoratee: BiometryDecorator(decoratee: MainQueueDispatchDecorator(decoratee: RandomUserAPIClient())))
let viewModel = UsersViewModel(usersRespository: usersRepository)
return UsersView(viewModel: viewModel)
}

Final Diagram

As you can see on the diagram the Presentation layer remains agnostic of any of the features we’ve added (Biometry, logs, threading, data source…) it just loads an array of User from a UserRepository and display it on a list.

Design Patterns relationships diagram

The infrastructure also remains agnostic of our App. The ApiClient, BiometryAuthenticator and LocalLogger know nothing about our App, they can be abstracted into their own modules and reused. We can also easily change our infrastructure and only change the related adapter. Ex: changing the LocalLogger and use Crashlytics instead, or use a different authentication mechanism.

Full diagram of composition root dependencies

Conclusion

You could add behavior without coupling the layers / components by using design patterns like Adapter, Decorator, Repository…

This allows us developers to develop, test and maintain the different layers in isolation making them simpler and easier to reuse and follow. We can finally glue everything together in the Composition Root where we compose the different components to create our App.

Don’t miss the second part of the post where I will explain how we can replace this Design Patterns with Combine Universal Abstractions.

You can download the project after applying the Design Patterns here. Don’t forget to read the second part.

--

--

Peña Fernando

iOS Developer with 10 years of experience that never stops training. I’m passionate for well structured and reusable code.