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 🙃