The video view model is no longer required to pass an access token to the API client if it requests the video of an episode. That is a welcome improvement. The API client passes an access token to an APIEndpoint object and it is the APIEndpoint object that decides when it is appropriate to add an Authorization header to a request. The changes we made in the previous episode improved the networking layer we are building.

We also introduced a problem in that episode. The API client is tightly coupled to the KeychainService class. That coupling decreases the testability of the networking layer and that is something we need to avoid. We decouple the KeychainService class from the API client in this episode using type erasure and protocol-oriented programming.

Dependency Injection

To decouple the keychain service from the API client, we start by injecting the keychain service into the API client. This is a good start because we can decide which KeychainService instance the API client uses to obtain an access token. Open APIClient.swift and declare a private, constant property, keychainService, of type KeychainService.

import Combine
import Foundation

final class APIClient: APIService {

    // MARK: - Properties

    private let keychainService: KeychainService

    ...

}

We use initializer injection to inject the KeychainService instance. Define an initializer that accepts a KeychainService instance as its only argument. In the body of the initializer, the API client stores a reference to the keychain service in its keychainService property.

// MARK: - Initialization

init(keychainService: KeychainService) {
    self.keychainService = keychainService
}

In the API client's request(_:) method, there is no need to create a KeychainService instance. The API client uses the keychain service that is injected during initialization.

private func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, APIError> {
    do {
        let accessToken = keychainService.accessToken
        let request = try endpoint.request(accessToken: accessToken)

        return URLSession.shared.dataTaskPublisher(for: request)
            ...
    } catch {
        ...
    }
}

Type Erasure and Protocol-Oriented Programming

Injecting the keychain service is a good start, but it isn't sufficient. The API client shouldn't have access to the complete API of the keychain service. It simply needs an object it can ask for an access token. We have a number of options to implement this requirement. The solution I have in mind uses type erasure and protocol-oriented programming.

While these patterns may seem daunting, the underlying ideas aren't complex. Let me explain how these patterns work. The keychain service is responsible for managing and providing access to the keychain. We don't want to and don't need to create another type that has access to the keychain. The KeychainService class does a fine job. That said, we don't want to expose the complete API of the KeychainService class to the API client. That is unnecessary.

What we need is an interface, a protocol, the API client can use to request an access token. We hide the keychain service from the API client behind that interface. That is a form of type erasure. Don't be confused by the terminology. It simply means that we don't expose the keychain service to the API client. We erase its type using a protocol.

Let's implement this solution to make it clear how the different pieces fit together. Add a Swift file to the Networking > Protocols group and name it AccessTokenProvider.swift. Define a protocol with name AccessTokenProvider. The protocol declares a single property, accessToken, of type String?.

import Foundation

protocol AccessTokenProvider {

    // MARK: - Properties

    var accessToken: String? { get }

}

The next step is conforming the KeychainService class to the AccessTokenProvider protocol. We don't need to update the implementation of the KeychainService class for this because KeychainService already declares a property with name accessToken of type String?.

import Combine
import Foundation
import KeychainAccess

final class KeychainService: AccessTokenProvider {

	...

}

With the AccessTokenProvider protocol in place, there is no longer a need to inject a KeychainService instance into the APIClient class. Replace the keychainService property with a property with name accessTokenProvider of type AccessTokenProvider.

import Combine
import Foundation

final class APIClient: APIService {

    // MARK: - Properties

    private let accessTokenProvider: AccessTokenProvider

	...

}

We also need to update the initializer. The initializer accepts an object that conforms to the AccessTokenProvider protocol.

// MARK: - Initialization

init(accessTokenProvider: AccessTokenProvider) {
    self.accessTokenProvider = accessTokenProvider
}

We change the keychainService property in the request(_:) method to the accessTokenProvider property.

private func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, APIError> {
    do {
        let accessToken = accessTokenProvider.accessToken
        let request = try endpoint.request(accessToken: accessToken)

        return URLSession.shared.dataTaskPublisher(for: request)
            ...
    } catch {
        ...
    }
}

To wrap up the implementation, we need to update the places where we create an APIClient instance. Pass a KeychainService instance to the initializer of the APIClient class in RootView.swift, EpisodeView.swift, and ProfileView.swift.

var body: some View {
    TabView {
        EpisodesView(viewModel: EpisodesViewModel(
            apiService: APIClient(
                accessTokenProvider: keychainService
            )
        ))
        .tabItem {
            Label("What's New", systemImage: "film")
        }
        ProfileView(viewModel: ProfileViewModel(keychainService: keychainService))
            .tabItem {
                Label("Profile", systemImage: "person")
            }
    }
}
var body: some View {
    VStack {
        ...
        .sheet(isPresented: $showVideo) {
            VideoView(
                viewModel: VideoViewModel(
                    videoID: viewModel.videoID,
                    apiService: APIClient(
                        accessTokenProvider: KeychainService()
                    )
                )
            )
        }
        ...
    }
}
var body: some View {
    VStack {
        if viewModel.isSignedIn {
            ...
        } else {
            SignInView(
                viewModel: SignInViewModel(
                    apiService: APIClient(
                        accessTokenProvider: viewModel.keychainService
                    ),
                    keychainService: viewModel.keychainService
                )
            )
        }
    }
}

It is important that you understand why we made this change because, at first glance, it seems as if we didn't gain anything. The API client still uses a keychain service to obtain an access token. The key difference is that it doesn't know that it uses a keychain service. From the perspective of the API client, it could be any object. The only requirement is that the object conforms to the AccessTokenProvider protocol. We take advantage of this when we write unit tests for the networking layer later in this series.

You may be wondering why it is so important that the API client doesn't have access to the complete API of the KeychainService class. First, an object should only know what it needs to know to do its job. This means that the API client only needs to know how to obtain an access token. If we expose the keychain service to the API client, it knows how to obtain a refresh token and it also knows how to reset the access token and the refresh token. At some point, it may be tempting to move that responsibility to the API client because it has access to the complete API of the keychain service. That is something we want to avoid.

Second, because the implementation of the KeychainService class is tied to the keychain, it is difficult to mock it in unit tests. By hiding the keychain service behind the AccessTokenProvider protocol, that becomes easy. We don't need to worry about the keychain. We need to create a type that conforms to the AccessTokenProvider protocol and vends access tokens.

What's Next?

Separation of concerns is a concept in software design that increases a project's testability and maintainability, and it also promotes reusability. I often talk about massive view controllers and how to use the Model-View-ViewModel pattern to put the view controllers of a project on a diet. But it is just as important to keep the other components of a project in check. Separation of concerns helps with that. It is better to create a handful of lightweight types with a single responsibility than a monolithic type with too many responsibilities.