12.
Adapter Pattern
Written by Jay Strawn
The adapter pattern is a behavioral pattern that allows incompatible types to work together. It involves four components:
-
An object using an adapter is the object that depends on the new protocol.
-
The new protocol is the desired protocol for use.
-
A legacy object existed before the protocol was made and cannot be modified directly to conform to it.
-
An adapter is created to conform to the protocol and passes calls onto the legacy object.
A great example of a physical adapter comes to mind when you consider the latest iPhone — there’s no headphone jack! If you want to plug your 3.5mm headphones into the lightning port, you need an adapter with a lightning connector on one end and a 3.5mm jack on the other.
This is essentially what the Adapter Pattern is about: connecting two elements that otherwise won’t “fit” with each other.
When should you use it?
Classes, modules, and functions can’t always be modified, especially if they’re from a third-party library. Sometimes you have to adapt instead!
You can create an adapter either by extending an existing class, or creating a new adapter class. This chapter will show you how to do both.
Playground example
Open IntermediateDesignPattern.xcworkspace in the Starter directory, or continue from your own playground workspace from the last chapter, then open the Adapter page.
For this example, you’ll adapt a third-party authentication service to work with an app’s internal authentication protocol. Add the following code, after Code Example:
import UIKit
// MARK: - Legacy Object
public class GoogleAuthenticator {
public func login(
email: String,
password: String,
completion: @escaping (GoogleUser?, Error?) -> Void) {
// Make networking calls that return a token string
let token = "special-token-value"
let user = GoogleUser(email: email,
password: password,
token: token)
completion(user, nil)
}
}
public struct GoogleUser {
public var email: String
public var password: String
public var token: String
}
Imagine GoogleAuthenticator is a third-party class that cannot be modified. Thereby, it is the legacy object. Of course, the actual Google authenticator would be a lot more complex; we’ve just named this one “Google” as an example and faked the networking call.
The login function returns a GoogleUser that has a string property called token. You might pass this token via a GET request like this:
https://api.example.com/items/id123?token=special-token-value
Or you might use this via Bearer authentication, such as a JSON Web Token (see https://jwt.io/). If you’re not familiar with these formats, that’s okay! They aren’t required knowledge for this chapter, but rather, they simply illustrate common use cases.
Next, add the following code to the end of the playground:
// MARK: - New Protocol
public protocol AuthenticationService {
func login(email: String,
password: String,
success: @escaping (User, Token) -> Void,
failure: @escaping (Error?) -> Void)
}
public struct User {
public let email: String
public let password: String
}
public struct Token {
public let value: String
}
This is the authentication protocol for your app which acts as the new protocol. It requires an email and password. If login succeeds, it calls success with a User and Token. Otherwise, it calls failure with an Error.
The app will use this protocol instead of GoogleAuthenticator directly, and it gains many benefits by doing so. For example, you can easily support multiple authentication mechanisms – Google, Facebook and others – simply by having them all conform to the same protocol.
While you could extend GoogleAuthenticator to make it conform to AuthenticationService — which is also a form of the adapter pattern! — you can also create an Adapter class. Add the following code to the end of the playground to do so:
// MARK: - Adapter
// 1
public class GoogleAuthenticatorAdapter: AuthenticationService {
// 2
private var authenticator = GoogleAuthenticator()
// 3
public func login(email: String,
password: String,
success: @escaping (User, Token) -> Void,
failure: @escaping (Error?) -> Void) {
authenticator.login(email: email, password: password) {
(googleUser, error) in
// 4
guard let googleUser = googleUser else {
failure(error)
return
}
// 5
let user = User(email: googleUser.email,
password: googleUser.password)
let token = Token(value: googleUser.token)
success(user, token)
}
}
}
Here’s what this does:
-
You create
GoogleAuthenticationAdapteras the adapter betweenGoogleAuthenticationAdapterandAuthenticationService. -
You declare a private reference to
GoogleAuthenticator, so it’s hidden from end consumers. -
You add the
AuthenticationServicelogin method as required by the protocol. Inside this method, you call Google’s login method to get aGoogleUser. -
If there’s an error, you call
failurewith it. -
Otherwise, you create
userandtokenfrom thegoogleUserand callsuccess.
By wrapping the GoogleAuthenticator like this, end consumers don’t need to interact with Google’s API directly. This protects against future changes. For example, if Google ever changed their API and it broke your app, you’d only need to fix it in one place: this adapter.
Add the following code to the end of the playground:
// MARK: - Object Using an Adapter
// 1
public class LoginViewController: UIViewController {
// MARK: - Properties
public var authService: AuthenticationService!
// MARK: - Views
var emailTextField = UITextField()
var passwordTextField = UITextField()
// MARK: - Class Constructors
// 2
public class func instance(
with authService: AuthenticationService)
-> LoginViewController {
let viewController = LoginViewController()
viewController.authService = authService
return viewController
}
// 3
public func login() {
guard let email = emailTextField.text,
let password = passwordTextField.text else {
print("Email and password are required inputs!")
return
}
authService.login(
email: email,
password: password,
success: { user, token in
print("Auth succeeded: \(user.email), \(token.value)")
},
failure: { error in
print("Auth failed with error: no error provided")
})
}
}
Here’s how this works:
-
You first declare a new class for
LoginViewController. It has anauthServiceproperty and text fields for the e-mail and password. In a real view controller, you’d create the views inloadViewor declare each as an@IBOutlet. For simplicity’s sake here, you set them to newUITextFieldinstances. -
You then create a class method that instantiates a
LoginViewControllerand setsauthService. -
Lastly, you create a
loginmethod that callsauthService.loginwith the e-mail and password from the text fields.
Next, add this code to try it out:
// MARK: - Example
let viewController = LoginViewController.instance(
with: GoogleAuthenticatorAdapter())
viewController.emailTextField.text = "user@example.com"
viewController.passwordTextField.text = "password"
viewController.login()
You here create a new LoginViewController by passing GoogleAuthenticatorAdapter as the authService, set the text for the e-mail and password text fields and call login.
You should see this printed to the console:
Auth succeeded: user@example.com, special-token-value
If you wanted to support other APIs like Facebook login, you could easily make adapters for them as well and have the LoginViewController use them exactly the same way without requiring any code changes.
What should you be careful about?
The adapter pattern allows you to conform to a new protocol without changing an underlying type. This has the consequence of protecting against future changes against the underlying type, but it also makes your implementation harder to read and maintain.
Be careful about implementing the adapter pattern unless you recognize there’s a real possibility for change. If there isn’t, consider if it makes sense to use the underlying type directly.
Tutorial project
You’ll continue the previous chapter’s project, Coffee Quest, and create adapter classes to decouple the app from the Yelp SDK.
If you skipped the previous chapter, or you want a fresh start, open Finder and navigate to where you downloaded the resources for this chapter. Then, open starter\CoffeeQuest\CoffeeQuest.xcworkspace (not .xcodeproj) in Xcode.
Note: If you opt to start fresh, then you’ll need to open up APIKeys.swift and add your Yelp API key. See Chapter 10, “Model-View-ViewModel Pattern” for instructions on how to generate this.
Open ViewController.swift, and you’ll see these two properties:
public var businesses: [YLPBusiness] = []
private let client = YLPClient(apiKey: YelpAPIKey)
Thereby, CoffeeQuest directly depends on YLPBusiness and YLPClient, which are two classes provided by the Yelp SDK. Hence, the app is tightly coupled to the Yelp SDK.
If the SDK ever changed, you’d need to update the app in multiple places. This isn’t a big problem right now because the app is small. However, it’s likely to cause problems later if you continued developing the app and using the SDK directly in many places.
It’d be better if the app depended on an intermediary protocol and conformed to it in only one place. Sound familiar? This is exactly what the adapter pattern is meant to do!
You’ll first create new groups and files to organize your new types.
Right click on the CoffeeQuest group, select New Group and name it Adapters. Repeat this to create a second group named Models and a third group named Protocols.
Next, right click on Adapters and select New File…. Select iOS ▸ Swift File and click Next. Call it YLPClient+BusinessSearchClient.swift and click Create.
Repeat this proccess to create a new filed called Business.swift under Models and BusinessSearchClient.swift under Protocols.
Lastly, right click on CoffeeQuest and select Sort by Name.
Your file hierarchy should now look like this:
You’ll need to use Business in the protocol and adapter, so you’ll create this type first. Replace the contents of Business.swift with the following:
import MapKit
public struct Business {
var name: String
var rating: Double
var location: CLLocationCoordinate2D
}
You’ll use this model instead of YLPBusiness directly. By doing so, you could later easily use other APIs like Google Places or MapKit and map their outputs to Business objects.
Next, replace the contents of BusinessSearchClient.swift with the following:
import MapKit
public protocol BusinessSearchClient {
func search(with coordinate: CLLocationCoordinate2D,
term: String,
limit: UInt,
offset: UInt,
success: @escaping (([Business]) -> Void),
failure: @escaping ((Error?) -> Void))
}
You’ll use this protocol instead of YLPSearch.
How exactly will you use YLPSearch? That’s where the adapter comes in. Finally, replace the contents of YLPClient+BusinessSearchClient.swift with the following:
import MapKit
import YelpAPI
// 1
extension YLPClient: BusinessSearchClient {
public func search(with coordinate: CLLocationCoordinate2D,
term: String,
limit: UInt,
offset: UInt,
success: @escaping (([Business]) -> Void),
failure: @escaping ((Error?) -> Void)) {
// 2
let yelpCoordinate = YLPCoordinate(
latitude: coordinate.latitude,
longitude: coordinate.longitude)
search(
with: yelpCoordinate,
term: term,
limit: limit,
offset: offset,
sort: .bestMatched,
completionHandler: { (searchResult, error) in
// 3
guard let searchResult = searchResult,
error == nil else {
failure(error)
return
}
// 4
let businesses =
searchResult.businesses.adaptToBusinesses()
success(businesses)
})
}
}
// 5
extension Array where Element: YLPBusiness {
func adaptToBusinesses() -> [Business] {
return compactMap { yelpBusiness in
guard let yelpCoordinate =
yelpBusiness.location.coordinate else {
return nil
}
let coordinate = CLLocationCoordinate2D(
latitude: yelpCoordinate.latitude,
longitude: yelpCoordinate.longitude)
return Business(name: yelpBusiness.name,
rating: yelpBusiness.rating,
location: coordinate)
}
}
}
Here’s how this works:
-
You extend
YLPClientto conform toBusinessSearchClient. This requires you to implementsearch(with:term:limit:offset:success:failure:). -
To perform a search, you convert the passed-in
CLLocationCoordinate2Dto aYLPCoordinateand callsearchonYLPClient. -
Within the
completionHandler, you verify there’s asearchResultand there’s not anerror. Otherwise, you call thefailurewith theerror. -
In the success case, you call
adaptToBusinesses()on thesearchResult.businessesto convert theYLPBusinessarray into aBusinessarray. -
You extend
ArraywhereElementisYLPBusiness. This allows you to create a convenience method to convert aYLPBusinessarray into aBusinessarray.
You’re now ready to actually use BusinessSearchClient and Business! Open ViewController.swift and replace this line
private let client = YLPClient(apiKey: YelpAPIKey)
With this instead, igorning the compiler error for now:
public var client: BusinessSearchClient =
YLPClient(apiKey: YelpAPIKey)
It’s subtle, but there are three significant changes here:
-
You changed
privatetopublic. Thereby, if you later wanted to use a different type ofBusinessSearchClient, you’d be able to get a reference to the view controller and set itsclient. -
You explicitly declare the property type as
BusinessSearchClient. This ensures that the compiler doesn’t automatically infer this to beYLPClient. -
You set this property to an instance of
YLPClientby default.
By making this change, you’ve actually decoupled the view controller from YLPClient! It now depends on BusinessSearchClient, and you can easily replace this with any other conforming type.
Next, replace the following line:
public var businesses: [YLPBusiness] = []
With the following:
public var businesses: [Business] = []
This is another step towards decoupling from from the Yelp SDK. However, you now have to fix the resulting compiler errors from these changes.
First, replace the contents of searchForBusinesses() with the following:
// 1
client.search(
with: mapView.userLocation.coordinate,
term: "coffee",
limit: 35, offset: 0,
success: { [weak self] businesses in
guard let self = self else { return }
// 2
self.businesses = businesses
DispatchQueue.main.async {
self.addAnnotations()
}
}, failure: { error in
// 3
print("Search failed: \(String(describing: error))")
})
Here’s how this works:
-
You update the callsight to use the method declared on
BusinessSearchClient, instead of the one fromYLPClient. -
If the search succeeds, you set
self.businessesto the fetchedbusinesses. You then dispatch to the main queue to add the annotations to the map. -
If the search fails, you simply print the error to the console.
You next need to update annotationFactory.createBusinessMapViewModel. This method currently expects a YLPBusiness for the input. Instead, you need to change this to accept a Business.
Open AnnotationFactory.swift, and replace these lines:
public func createBusinessMapViewModel(
for business: YLPBusiness) -> BusinessMapViewModel? {
guard let yelpCoordinate =
business.location.coordinate else {
return nil
}
let coordinate = CLLocationCoordinate2D(
latitude: yelpCoordinate.latitude,
longitude: yelpCoordinate.longitude)
With the following:
public func createBusinessMapViewModel(
for business: Business) -> BusinessMapViewModel {
let coordinate = business.location
There’s three small changes here:
-
You changed the input parameter’s type to
Business. -
You changed the return type to
BusinessMapViewModelinstead of an optional,BusinessMapViewModel?. -
You get the
coordinatefrom thebusiness.location, which doesn’t require aguardbecause it’s not an optional type.
There’s just one final change you need to make to get the project to compile again. Open ViewController.swift, and you’ll find there’s a compiler error within addAnnoations().
Replace this code:
guard let viewModel =
annotationFactory.createBusinessMapViewModel(for: business)
else {
continue
}
With the following instead:
let viewModel =
annotationFactory.createBusinessMapViewModel(for: business)
This removes the guard because the return type is no longer optional.
Now that you’ve adapted your code, build and run to confirm everything works as expected.
Key points
You learned about the adapter pattern in this chapter. Here are its key points:
-
The adapter pattern is useful when working with classes from third-party libraries that cannot be modified. You can use protocols to have them work with the project’s custom classes.
-
To use an adapter, you can either extend the legacy object, or make a new adapter class.
-
The adapter pattern allows you to reuse a class even if it lacks required components or has incompatible components with required objects.
-
In A Briefer History of Time, Steven Hawking said, “Intelligence is the ability to adapt to change.” Maybe he wasn’t talking about the adapter pattern exactly, but this idea is an important component in this pattern and many others: plan ahead for future changes.
Coffee Quest is getting better with every refactor! Once again, not much has changed in the app from a visual perspective, but now it will be so much easier to add other APIs and have them work seamlessly with your Business objects.
In the next chapter, you’ll learn about the iterator pattern. The current mechanism of iterating through businesses in the view controller is less than ideal. You’ll learn how to extend classes to make them iterable and easier to manage.