The analytics library we are building is tightly coupled to the GoogleAnalyticsClient class. Tight coupling negatively impacts testability and it is often an indication that there is room for improvement. In this episode, we decouple the analytics library from the GoogleAnalyticsClient class by taking a protocol-oriented approach. Let me show you how that works.
Defining an Interface
We first declare a protocol that defines an interface. The analytics library uses that interface to integrate with the Google SDK. That idea is common in Swift development and it is easy to implement.
Create a group and name it Protocols. Add a Swift file to the Protocols group, name it AnalyticsService.swift, and declare a protocol with name AnalyticsService.
import Foundation
protocol AnalyticsService {
}
The AnalyticsService protocol defines an application programming interface, API or interface for short. Declare a method with name send(event:). The send(event:) method accepts an argument, event, of type Journey.Event.
import Foundation
protocol AnalyticsService {
func send(event: Journey.Event)
}
Integrating the Interface
The AnalyticsService protocol decouples Journey from the Google SDK. Journey is only aware of the AnalyticsService protocol. It is no longer aware of the Google SDK and its GoogleAnalyticsClient class. That means we need to replace references to the GoogleAnalyticsClient class with the AnalyticsService protocol.
Open Journey.swift and navigate to the Event struct. The analyticsService parameter is no longer of type GoogleAnalyticsClient. We change its type to AnalyticsService. This also means the default value of the analyticsService parameter can no longer be the GoogleAnalyticsClient singleton.
func send(to analyticsService: AnalyticsService) {
...
}
We also need to update the implementation of the send(to:) method of the Event struct. Remove the body of the send(to:) method. The updated implementation is much simpler. We invoke the send(event:) method on the AnalyticsService object, passing in self, the Event object.
func send(to analyticsService: AnalyticsService) {
analyticsService.send(event: self)
}
Conforming to the Interface
Journey is no longer able to send events using the Google SDK. We can restore that ability by conforming the GoogleAnalyticsClient class to the AnalyticsService protocol. You could say that the GoogleAnalyticsClient class needs to conform to the AnalyticsService protocol to bridge the gap between Journey and the Google SDK. The AnalyticsService protocol acts as a bridge without tightly coupling Journey and the Google SDK.
Add a Swift file to the Extensions group and name it GoogleAnalyticsClient+AnalyticsService.swift. Create an extension for the GoogleAnalyticsClient class and conform it to AnalyticsService.
extension GoogleAnalyticsClient: AnalyticsService {
}
The benefit of a protocol-oriented approach is that the compiler checks your work. It throws an error because the GoogleAnalyticsClient class doesn't conform to the AnalyticsService protocol. Click the Fix button to add a stub for the send(event:) method.

The implementation of the send(event:) method should look familiar. We declare a variable dictionary with name properties. The keys of the dictionary are of type String and the values are of type Any. We set its initial value to an empty dictionary.
func send(event: Journey.Event) {
var properties: [String: Any] = [:]
}
We use the forEach(_:) method to iterate through the properties of the Event object. In the closure we pass to the forEach(_:) method, we add the property to the properties dictionary using the computed name and value properties of the Property enum.
func send(event: Journey.Event) {
var properties: [String: Any] = [:]
event.properties.forEach { property in
properties[property.name] = property.value
}
}
We then invoke the trackEvent(with:properties:) method of the GoogleAnalyticsClient class, passing in the name of the event and the dictionary of properties.
func send(event: Journey.Event) {
var properties: [String: Any] = [:]
event.properties.forEach { property in
properties[property.name] = property.value
}
trackEvent(with: event.name, properties: properties)
}
Updating the Notes View Model
Before we can build the target, we need to make a few changes to the implementation of the notes view model. Open NotesViewModel.swift. The send(to:) method of the Event struct no longer defines a default value so we need to pass an object to the send(to:) method that conforms to the AnalyticsService protocol.
Declare a private computed property with name analyticsService of type AnalyticsService. In the body of the computed property, we return a reference to the GoogleAnalyticsClient singleton.
import Foundation
internal final class NotesViewModel {
// MARK: - Properties
private var analyticsService: AnalyticsService {
GoogleAnalyticsClient.shared
}
...
}
In the createNote(_:), updateNote(_:), and deleteNote(_:) methods, we pass the analytics service to the send(to:) method of the Event object.
import Foundation
internal final class NotesViewModel {
// MARK: - Properties
private var analyticsService: AnalyticsService {
GoogleAnalyticsClient.shared
}
// MARK: - Public API
func createNote(_ note: Note) {
Journey.createNote
.properties(
.source(.home),
.kind(note.kind)
).send(to: analyticsService)
}
func updateNote(_ note: Note) {
Journey.updateNote
.properties(
.source(.home),
.kind(note.kind),
.wordCount(note.wordCount)
).send(to: analyticsService)
}
func deleteNote(_ note: Note) {
Journey.deleteNote
.properties(
.kind(note.kind),
.wordCount(note.wordCount)
).send(to: analyticsService)
}
}
Build the target to make sure we fixed what we broke. You should see no errors or warnings.
Benefits of a Protocol-Oriented Approach
The AnalyticsService protocol opens up a few possibilities. Later in this series, we write unit tests for Journey and decoupling the analytics library from the Google SDK is essential to make that straightforward. The protocol-oriented approach has another important benefit. It is now trivial to replace the Google SDK with another third party analytics service.
Using a Dependency Injection Container
Before we end this episode, I would like to make one more improvement to simplify the analytics library at the call site. You may have noticed that the project has one dependency, Swinject, a popular dependency injection library. We use Swinject in this series, but you can use whatever dependency injection framework you prefer. I won't explain how Swinject works in this series and that isn't a requirement to follow along.
Open Container+Helpers.swift in the Extensions group. The Container class defines a static constant property with name shared. It provides access to a dependency injection container through which services can be registered and accessed. That is the idea of a dependency injection container.
We define a static computed property for the analytics service. We name it analyticsService and it is of type AnalyticsService. In the body of the computed property, we invoke the resolve(_:) method on the Container instance to access the analytics service that is registered with the dependency injection container. Note that we force unwrap the return value of the resolve(_:) method.
import Swinject
extension Container {
// MARK: - Properties
static let shared = Container()
// MARK: - Services
static var analyticsService: AnalyticsService {
shared.resolve(AnalyticsService.self)!
}
}
We can only access a service through the dependency injection container if it is registered with the dependency injection container. Open AppDelegate.swift. We invoke the setupDependencies() method, a helper method, the moment the application finishes its launch sequence.
// MARK: - Application Life Cycle
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
setupDependencies()
return true
}
In setupDependencies(), we register an analytics service with the dependency injection container by invoking register(_:name:factory:) on the Container instance. The first argument is the type of the service we register. The last argument is a closure that returns the service we want to expose through the dependency injection container. Swinject doesn't care which object the closure returns as long as its type matches the first argument, the type of the service. In this example, we create a GoogleAnalyticsClient instance and return it from the closure.
// MARK: - Helper Methods
private func setupDependencies() {
Container.shared.register(AnalyticsService.self) { _ in
GoogleAnalyticsClient(
apiKey: Configuration.Google.apiKey,
clientSecret: Configuration.Google.clientSecret
)
}
}
By accessing the analytics service through the dependency injection container, we can simplify the implementation quite a bit. Revisit Journey.swift and add an import statement for Swinject at the top.
import Swinject
import Foundation
internal enum Journey {
...
}
Navigate to the send(to:) method of the Event struct. We set the default value of the analyticsService parameter to the analytics service the dependency injection container returns.
func send(to analyticsService: AnalyticsService = Container.analyticsService) {
analyticsService.send(event: self)
}
Head back to NotesViewModel.swift. Because the send(to:) method of the Event struct defines a default value, we are no longer required to pass an analytics service to the send(to:) method. This also means we can remove the analyticsService property. We no longer need it.
import Foundation
internal final class NotesViewModel {
// MARK: - Public API
func createNote(_ note: Note) {
Journey.createNote
.properties(
.source(.home),
.kind(note.kind)
).send()
}
func updateNote(_ note: Note) {
Journey.updateNote
.properties(
.source(.home),
.kind(note.kind),
.wordCount(note.wordCount)
).send()
}
func deleteNote(_ note: Note) {
Journey.deleteNote
.properties(
.kind(note.kind),
.wordCount(note.wordCount)
).send()
}
}
Last but not least, we can remove the static shared property of the GoogleAnalyticsClient class. The GoogleAnalyticsClient instance is now accessed through the dependency injection container. Build the target one more time to make sure we didn't break anything.
What's Next?
We now have a solution that combines the best of both worlds. The Journey library is testable and it isn't tightly coupled to the Google SDK. At the same time, we built an API that is easy to use and defaults to the analytics service that is registered with the dependency injection container.
If you prefer to not use a default value for the analyticsService parameter of the send(to:) method, then that is fine too. In that case, I suggest you inject the analytics service into the notes view model and pass it to the send(to:) method.