Scalability & Maintenance in IOS

Rotem Nevgauker
10 min readSep 30, 2023

--

A Deep Dive into Clean Architecture in Swift (with an AI buddy)

In the ever-evolving and changing world of iOS app development, creating software that is not only functional but also easy to maintain and scale is a paramount concern. Clean architecture has emerged as a powerful methodology to address these challenges, offering developers a structured approach to building robust and maintainable applications.

Here I will discuss how it can be implemented in Swift using examples from a project I built recently.

This project is based on a flutter implementation and I took the liberty of converting and adapting it to the iOS world.

My AI copilot helped along the way by solving any problems in a creative way, adapting, and of course writing tests.

A few things you should be aware of:

I used completion blocks and not the newer await / async. I tried to use you at some point but I came to the conclusion that they don’t play well together, especially in a layered architecture. So choose one and stick to it!

I heavily used an enum for the returned value for most layers and even utilities.

Haven’t done this before but this looks like a cool way to control the entire data flow of the app . might help with the logging as usually this is a cross-layered component and fulfills different development and production purposes. On the other hand, it might fill your code with many switch cases you don’t necessarily need.

This is the project I got my examples, it is far from perfect but I got a lot of insights from it:

A short preview of it. It contains a single feature that allows the user to pick a positive number, and get some interesting information about it. He can also pick random numbers. The last number is cached and presented when there is no connection. numbers are fetched from a simple API which is not part of this project.

So to summarize the requirements that soon will be our domain layer

  1. Fetch a number the user picked from the API, cache it, and show to the user information about it.

*. If there is no connection, show the cached number.

  1. Fetch a random number from the API, cache it, and show information about it to the user.

*. If there is no connection, show the cached number.

Why Architecture Matters

Let's first understand why selecting the right architecture matters. This is not specific to Swift and matters whatever language and technologies you are using.

  1. Scalability

As your app grows in complexity, having a well-defined architecture becomes essential for managing that complexity. Clean architecture provides a clear separation of concerns, making it easier to scale your application without turning it into an unmanageable mess of code.

2. Maintainability

Swift apps often have a long lifespan, with regular updates and feature additions. Clean architecture’s modularity and organization ensure that maintaining and extending your codebase over time is less error-prone and more straightforward.

3. Testability

Testing is a fundamental aspect of ensuring the reliability of your app. Clean architecture promotes testability by allowing you to isolate and test individual components of your codebase in isolation. This leads to more robust and bug-free applications.

4. Collaboration

In collaborative development environments, where multiple developers work on the same codebase, clean architecture provides a clear structure that facilitates teamwork. Each developer can focus on specific components without interfering with others’ work.

Layers of Clean Architecture

At its core, clean architecture is about structuring your codebase in a way that separates the high-level policy from the low-level details, promoting a clean and maintainable design.

This separation is achieved through distinct layers, each with its responsibilities:

  • Presentation Layer: Responsible for user interface and interaction. It’s where your app’s user interface components reside.
  • Domain Layer: Contains the business logic and use cases. It’s the heart of your application, where the core functionality is defined.
  • Data Layer: Manages data retrieval and storage. It abstracts data sources, allowing flexibility in choosing databases, network protocols, or other data providers.

Separation of Concerns

In clean architecture, dependencies flow inward, with high-level layers depending on lower-level layers, ensuring that changes in one layer don’t ripple through the entire application.

Both the layers and the flow of dependency allow the separation of concern creating modularity that helps us with all that matters when we choose the architecture.

Things are easier to test, which in turn improves maintenance and collaboration between developers.

That allows efficient work as the project gets bigger and more people get involved.

That is why architecture is especially recommended for big projects but I think even smaller projects can adopt some of that concept that will be beneficial even for them.

Implementing Clean Architecture in Swift

Domain

In the realm of software architecture, there can often be a degree of ambiguity, as the concept of clean architecture is portrayed in a somewhat abstract manner. Various individuals interpret its terminology differently, and even identical terms may carry disparate meanings to different people.

However, when considering the alignment of our system with the specific needs of the business it serves, it becomes apparent that there may be a more structured perspective worth adopting. The genesis of any software development endeavor invariably lies in a set of requirements, whether meticulously documented for broader consumption or jotted down informally on a napkin, destined for the sole eyes of the originator.

In essence, this foundational principle remains consistent: comprehending what the system is designed to accomplish and the underlying mechanisms through which it achieves those objectives. To employ a grammatical analogy, one could liken this to the grammatical constructs of a language — a structure and framework that holds true irrespective of individual preferences.

Consider, for instance, the development of a bookstore system. In this scenario, the core focus centers on books and their distribution to customers. Similarly, when delivering video content to customers through a streaming platform, the primary elements are videos and the act of streaming them.

In essence, the fundamental building blocks, or “entities,” in these scenarios are the books and videos. These entities constitute the lowest and often simplest layer of the system, one that remains relatively independent of the other layers in the architecture.

Here is how entities look in my project :

import Foundation

class NumberTrivia: Equatable {
let text: String
let number: Int

init(text: String, number: Int) {
self.text = text
self.number = number
}

static func == (lhs: NumberTrivia, rhs: NumberTrivia) -> Bool {
return lhs.text == rhs.text && lhs.number == rhs.number
}
}

Here is how use cases abstraction looks in my project :


import Foundation

protocol UseCase {
associatedtype TheType
associatedtype Params

func call(params: Params?, completion: @escaping (Either<Failure, TheType>) -> Void)

}

class NoParams: Equatable {
static func == (lhs: NoParams, rhs: NoParams) -> Bool {
return lhs === rhs
}
}

Above it and dependent on it in the hierarchy, is the repository, returning data from a variety of sources. This one basically lives on the border between the domain and the data layers, and there can be a separation between the two. the domain will hold the abstract version ( or protocol for us) and the data will hold the implementation.

Here are the repository abstraction looks in my project :

import Foundation


protocol NumberTriviaRepository {
func getConcreteNumberTrivia(params: Params?, completion: @escaping (Either<Failure, NumberTrivia>) -> Void)
func getRandomNumberTrivia(completion: @escaping (Either<Failure, NumberTrivia>) -> Void)
}

Here is a repository mockup I used for testing :

import Foundation


class MockNumberTriviaRepository: NumberTriviaRepository {

let isConnected = true
func getConcreteNumberTrivia(params: Params?, completion: @escaping (Either<Failure, NumberTrivia>) -> Void) {
guard let p = params else { return completion(.left(Failure.missingParams))}

// Simulate a successful response with some test data
if (isConnected){
let trivia = NumberTrivia(text: "Test trivia", number: p.number)
completion(.right(trivia))
}else{
let trivia = NumberTrivia(text: "Test trivia local", number: p.number)
completion(.right(trivia))
}
}

func getRandomNumberTrivia(completion: @escaping (Either<Failure, NumberTrivia>) -> Void) {
// Simulate a successful response with some test data
let trivia = NumberTrivia(text: "Random trivia", number: 42)
completion(.right(trivia))
}

}

Abstraction through protocol is a great concept to enable write testing using mock data for separate parts and moving along the layers . As the domain is the most independent layer it is also a good place to start but that is more a personal preference thing in my option

The subsequent component pertains to the use cases, encompassing all the business-centric actions. These use cases are expected to mirror the system’s requisites in a technically precise fashion.

For instance, if you are engaged in the development of a machine designed to harvest apples from trees, a corresponding use case, such as “pick an apple,” should be delineated, specifying its functionality of retrieving an apple. This use case, like others, should ideally be constructed employing protocols, a practice that facilitates early-stage testing with mock data.

Data

This layer got me personally confused as it is the gateway from our app to all the good and the bad, this layer should be extra strict as it is the last line of defense between what we control and what we can’t.

Even so, regarding our own clean world, it is not the most external in our layers.

There are reasons for that. This layer implements the abstract repository from the domain layer and returns entities to the layer about it.

So, it is above the domain layer but not the top layer

The layer has a separate data class. This class handles all the data conversion and can be easily converted to a struct for easy decoding.

Here are the data sources abstraction & mockups:


import Foundation

protocol NumberTriviaLocalDataSource {
/// Gets the cached [NumberTriviaModel] which was gotten the last time
/// the user had an internet connection.
///
/// Throws [NoLocalDataException] if no cached data is present.
func getLastNumberTrivia(completion: @escaping (Either<ServerException, NumberTriviaModel>) -> Void)

func cacheNumberTrivia(triviaToCache:NumberTriviaModel,completion: @escaping (Either<ServerException, NumberTriviaModel>) -> Void)
}


class NumberTriviaLocalDataSourceImpl : NumberTriviaLocalDataSource{

let NumberKey = "number"
let TextKey = "text"
let standard = Foundation.UserDefaults.standard

func getLastNumberTrivia(completion: @escaping (Either<ServerException, NumberTriviaModel>) -> Void){
let text = standard.string(forKey: TextKey)
if text == nil {
completion(.left(.CacheException))
return
}
let number = standard.integer(forKey: NumberKey)
let obj = NumberTriviaModel(text: text!, number: number)
completion(.right(obj))
}

func cacheNumberTrivia(triviaToCache:NumberTriviaModel,completion: @escaping (Either<ServerException, NumberTriviaModel>) -> Void){
standard.set(triviaToCache.number, forKey: NumberKey)
standard.set(triviaToCache.text, forKey: TextKey)
completion(.right(triviaToCache))

}
}
import Foundation

protocol NumberTriviaRemoteDataSource {
/// Calls the http://numbersapi.com/{number} endpoint.
///
/// Throws a [ServerException] for all error codes.
func getConcreteNumberTrivia (number:Int,completion: @escaping (Either<ServerException, NumberTriviaModel>) -> Void)
/// Calls the http://numbersapi.com/random endpoint.
///
/// Throws a [ServerException] for all error codes.
func getRandomNumberTrivia(completion: @escaping (Either<ServerException, NumberTriviaModel>) -> Void)
}

class NumberTriviaRemoteDataSourceImpl : NumberTriviaRemoteDataSource{
func getRandomNumberTrivia(completion: @escaping (Either<ServerException, NumberTriviaModel>) -> Void) {
let urlStr = "http://numbersapi.com/random?json"
if let url = URL(string:urlStr) {
let session = URLSession.shared
let task = session.dataTask(with: url) { (data, response, error) in
if error == nil {
if let data = data {

do {
if let jsonDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
// Now, jsonDict contains the JSON data as a [String: Any] dictionary
print(jsonDict)

if let number = jsonDict["number"] as? Double {
// Check if the number is not bigger than the maximum possible Double value
if number <= Double.greatestFiniteMagnitude {
print("The number is within the valid range for Double.")
} else {
print("The number is too big for a Double.")
completion(.left(ServerException.ServerException))
}
}




let obj = NumberTriviaModel.fromJson(json: jsonDict)
if let returnedObject = obj {
completion(.right(returnedObject))
}else{
completion(.left(ServerException.ServerException))
}
}
} catch {
completion(.left(ServerException.JSONSerializationException))
}
}
}
}
task.resume()
}

}

func getConcreteNumberTrivia(number: Int, completion: @escaping (Either<ServerException, NumberTriviaModel>) -> Void) {
let urlStr = "http://numbersapi.com/" + String(number) + "?json"
if let url = URL(string:urlStr) {
let session = URLSession.shared
let task = session.dataTask(with: url) { (data, response, error) in
if error == nil {
if let data = data {

do {
if let jsonDict = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] {
// Now, jsonDict contains the JSON data as a [String: Any] dictionary
print(jsonDict)
let obj = NumberTriviaModel.fromJson(json: jsonDict)
if let returnedObject = obj {
completion(.right(returnedObject))
}else{
completion(.left(ServerException.ServerException))
}
}
} catch {
completion(.left(ServerException.JSONSerializationException))
}
}
}
}
task.resume()
}

}

}

Here my repository was implemented, using two data sources (local and remote) fetching the data and creating a data model.

The mode then was converted to an entity which was returned to whoever used the repository ( use cases ).

My repository took the responsibility of converting it to an entity. This way you don’t have to keep data you don’t need and can normalize different data models to single entities to keep all the “noise” outside your code.

Presentation

This layer is all about the UI elements, the sub-element, and any elements responsible for modifying the UI elements. For us, it's where the view controllers or swift ui views live. This is also where to maintain your state and triggers for UI modifications.

In my example, I can tell you I had a pages subfolder with all my view controllers. There was an observation on a single parameter which changed after a singleton I created triggered a use case.

This is very much dependent on the app, use cases, and how often the use cases were triggered but in my case, it was enough.

Testing Cleanly

We all know how many tests there are. I found myself writing them very naturally before I wrote code ( TDD) or just after. The mix of using protocol for the abstraction of the important part, while creating mockups for other parts the class is dependent on feels like it progresses very organically.

I started from the domain layer, testing the use cases using a mockup repository.

Move some JSON utilities and then the data layer, testing the data sources separately and the model, which used the utilities.

From there I moved on to the actual implementation of the repository, using the actual data sources and testing it as well.

The last tests I wrote were some more utilities for monitoring the network status and data conversion from the user input to an actual valid number.

Check out the tests in the project

Common Pitfalls and Best Practices

For obvious reasons, you do not need all of this for every project. In this project, I created one feature in a manner that prepares the project for many many more.

But if your project has only a few features and does not expect to grow, you don’t need all of this!

Nonetheless, I do think that even when preferring MVC or MVVM for smaller projects you can definitely pick and choose pieces that benefit you the most.

Things like: using protocol as much as you can, especially for the core of your app, and testing them. you don’t need to test every simple utility but for the important parts, definitely.

Make it as modular as possible, know where the border between things you can control and things you can’t, external API and DBS, sockets, and more.

Plan to protect and test that border wall. you will avoid so many problems before they accrue and this is definitely a time well spent no matter the size of your project.

The code & this article are based on this tutorial

--

--

Rotem Nevgauker

Started as a ios developer in small & medium sized startup companies in various industries. Made the transition to become a fully remote full stack freelancer