Decoupling Dependencies in Swift

Using third-party dependencies in iOS projects is very common and nearly unavoidable. The problem is that developers tend to mix the code for interacting with the dependency with the rest of the code in their project, as opposed to isolating that code into a separate class. This can become a problem when trying to reuse functionality, change the dependency being used, or simply test it's functionality.

In this tutorial, we will abstract our dependency code into a separate class that is reusable, flexible, and testable.

Let take a look at what it might look like to have our app's code mixed with a third-party dependency's code:

import Amplify
import SwiftUI

struct ContentView: View {
    @State var currentTodo: Todo?
    
    var body: some View {
        VStack {
            if let todo = currentTodo {
                Text("Current todo: \(todo.name)")
            }
            Button("Create Todo", action: createTodo)
        }
    }
    
    func createTodo() {
        let todo = Todo(name: "Write some code")
        Amplify.DataStore.save(todo) { result in
            self.currentTodo = try? result.get()
        }
    }
}

In this example, we are using AWS Amplify to create a Todo object, then display a Text view when currentTodo has a value. The Amplify code is written alongside our SwiftUI code and includes the import Amplify line at the top.

So what's the problem?

If Todos are created somewhere else in the app, then it would require copying or rewriting this functionality. If we want to switch from using Amplify to using something else, then we have to go back and update all the places that are creating Todos or anything else Amplify is handling for us. Also, it's impossible to test the createTodo functionality alone without testing the SwiftUI view itself.

This concept can be applied to any number of third-party dependencies like: Alamofire, KingFisher, etc.

We can fix the reusability issue by simply abstracting the createTodo functionality to another class:

import Amplify

class AmplifyDataService {
    func createTodo(completion: @escaping (Todo?) -> Void) {
        let todo = Todo(name: "Write some code")
        Amplify.DataStore.save(todo) { result in
            try? completion(result.get())
        }
    }
}

AmplifyDataService can act as a module dedicated to handling all interactions with Amplify DataStore. Whenever the app needs to use the createTodo functionality, it will simply require making an instance of AmplifyDataService and calling the method, which will provide consistent behavior across the app. Using the same function everywhere allows the implementation to change, but the call sites to be unaffected.

The ContentView will now look something like this:

import SwiftUI

struct ContentView: View {
    let dataService = AmplifyDataService()
    @State var currentTodo: Todo?
    
    var body: some View {
        VStack {
            if let todo = currentTodo {
                Text("Current todo: \(todo.name)")
            }
            Button("Create Todo") {
                dataService.createTodo { self.currentTodo = $0 }
            }
        }
    }
}

Notice that there is no longer the need to include the import Amplify line at the top of the file. The Create Todo button is simplify calling createTodo from the new instance of AmplifyDataService initialized at the top of the struct.

This resolves the reusability issue, but what about flexibility and testability?

Both of these problems can be solved with a single language feature: protocols.

Protocols hide the implementation details and simply specify the capabilities of an object. We can create one to define the expectations of a DataService:

protocol DataService {
    func createTodo(completion: @escaping (Todo?) -> Void)
}

The DataService protocol now represents all the public facing methods we want a concrete class to expose; in the case of AmplifyDataService, that's just createTodo.

Now to update AmplifyDataService to conform to DataService:

... // import Amplify

class AmplifyDataService: DataService {

... // func createTodo(completion: @escaping (Todo?) -> Void) {

Then change all places that create an instance of AmplifyDataService to have an explicit type of DataService. The initial change in ContentView may look like this:

... // struct ContentView: View {

let dataService: DataService = AmplifyDataService()

... // @State var currentTodo: Todo?

At this point, the code is fairly flexible. All interactions with dataService will be based on the DataService protocol, and AmplifyDataService just happens to be an instance that's being used since it conforms to DataService.

We could take this one step further by changing the initializer of ContentView to accept a DataService object as a parameter. Then, any DataService object can be used to create a ContentView:

... // struct ContentView: View {

let dataService: DataService

... // @State var currentTodo: Todo?

init(dataService: DataService = AmplifyDataService()) {
    self.dataService = dataService
}

... // var body: some View {

It's a small change, but now we can pass in different DataService objects to the ContentView and it is unlikely to need updating in the future. There can be a PreviewDataService passed to the ContentView_Preview for rending the SwiftUI previews, or MockDataService used while writing tests.

When you're ready to start testing the concrete classes like AmplifyDataService you could do something like:

class ios_appTests: XCTestCase {
    let sut = AmplifyDataService()
    
    func testCreateTodo() throws {
        sut.createTodo { todo in
            XCTAssertNotNil(todo)
        }
    }
}

If testing Amplify, be sure to configure Amplify in the setUp method of your test class.

Making a few changes like abstracting dependency code to a separate class and using some protocol-oriented programming will make your code more reusable, flexible, and testable. I'd suggest going down this path sooner as opposed to later because we all know you won't have time to actually go back and refactor it later 🙃