Adapter pattern in real-life
Anybody who has worked with me knows that I’m a fan of the original Design Patterns book, I remember buying it more than 20 years ago in the old Borders in the centre of Glasgow, and reading it and thinking it was a bit dry to read, but really interesting, I have a memory of implementing the State pattern in Python with the old Python Tk bindings in a job I left in June 2000.
I do accept that folks can tend to overuse the patterns, and that as time as passed, they’ve not all stood the test of time as well.
But, they do provide a useful framework for discussing things, the words “factory”, “builder”, “observer”, “visitor” and “singleton” are all easily recognisable in software engineering, thanks in large part to the success of the “Gang of Four” book.
One of the most recognisable patterns is the “Adapter” pattern, and it’s one I use a lot in the types of projects I’ve been involved in over the past few years, where systems need to talk to different (but similar) things, it’s also core to the Hexagonal Architecture, in its “Ports and Adapters” alias.
From pg.139 of the original Design Patterns book:
Intent
Convert the interface of a class into another interface clients expect. Adapter lets classes work together that couldn’t otherwise because of incompatible interfaces.
Also Known As Wrapper
The original chapter talks about “Class adapters” and “Object adapters”, and it’s “Class adapters” I’ll talk about here.
Basically, clients talk to an object that implements an interface (in this example it’s Operator), and they delegate to another class (the Adaptee) and call a method on that to do the work.
From the client’s point of view, it’s isolated from the SpecificOperation, it can talk to any implementation of the Operator interface, and it should adhere to the interface contract.
Non-adapter version
Adapters almost always come from refactoring code, and changing requirements, it’s pretty rare in my experience to start with one.
So, I’ll implement some simple functionality, and look at how the pattern helps simplify the behaviour.
For this example, I’ll illustrate by reading a file from a repo in Github, println
will stand-in for actually processing the file that’s read.
Using GitHub API For Java in Kotlin, it looks like this:
package com.bigkevmcd.client
import org.kohsuke.github.GitHub
fun getFile(repo: String, ref: String, filename: String): String {
return GitHub
.connectAnonymously()
.getRepository(repo)
.getFileContent(filename, ref)
.read()
.readAllBytes()
.toString(Charsets.UTF_8)
}
fun main(args: Array<String>) {
println(getFile("bigkevmcd/github-tool", "master", "README.md"))
}
Without the nice spacing, this is only six lines of code, pretty short and simple.
In the main, the println
doesn’t know about how that code is fetching the file, it just calls getFile
with the correct parameters.
A nice, simple way of reading files from GitHub…and then someone comes along and asks for GitLab support (yeah, customers), you argue that “everybody uses GitHub anyway”, but to no avail, you’ve got to support GitLab (just be glad nobody’s asked for Bitbucket yet).
A simple attempt might look like this, using GitLab4j.
import org.gitlab4j.api.GitLabApi
fun main(args: Array<String>) {
val repo = "bigkevmcd/github-tool"
val ref = "master"
val filename = "README.md"
val readme = when(args[0]) {
"github" -> GitHub
.connectAnonymously()
.getRepository(repo)
.getFileContent(filename, ref)
.read()
.readAllBytes()
.toString(Charsets.UTF_8)
"gitlab" -> GitLabApi("https://gitlab.com/", System.getenv("GITLAB_TOKEN"))
.repositoryFileApi
.getFile(repo, filename, ref)
.decodedContentAsString
else -> "unknown git host"
}
println(readme)
}
This works, but it can definitely be improved:
enum class GitHost {
GitHub, GitLab
}
fun getFile(type: GitHost, repo: String, ref: String, filename: String): String {
return when(type) {
GitHost.GitHub -> GitHub
.connectAnonymously()
.getRepository(repo)
.getFileContent(filename, ref)
.read()
.readAllBytes()
.toString(Charsets.UTF_8)
GitHost.GitLab -> GitLabApi("https://gitlab.com/", System.getenv("GITLAB_TOKEN"))
.repositoryFileApi
.getFile(repo, filename, ref)
.decodedContentAsString
}
}
fun main(args: Array<String>) {
// This should use args to pick a GitHost enum
println(getFile(GitHost.GitHub, "bigkevmcd/github-tool", "master", "README.md"))
}
And this clearly works well enough, but the println
has gone from not knowing how to get the file, to passing it in.
It’s a bit tricky to work out how I’d customise the GitHub or GitLab endpoints tho’, so I might refactor it to something like:
class GitClient(val github: GitHub, val gitlab: GitLabApi) {
fun getFile(type: GitHost, repo: String, ref: String, filename: String): String {
return when(type) {
GitHost.GitHub -> github
.getRepository(repo)
.getFileContent(filename, ref)
.read()
.readAllBytes()
.toString(Charsets.UTF_8)
GitHost.GitLab -> gitlab
.repositoryFileApi
.getFile(repo, filename, ref)
.decodedContentAsString
}
}
}
fun main(args: Array<String>) {
val client = GitClient(GitHub.connectAnonymously(), GitLabApi("https://gitlab.com/", System.getenv("GITLAB_TOKEN"))
println(client.getFile(GitHost.GitHub, "bigkevmcd/github-tool", "master", "README.md"))
}
This would at least allow configuration of the authentication and endpoint, but
the println
still needs to know what type to use for the client.
At this point, an adapter becomes really useful:
Adapter version
interface GitHost {
fun getFile(repo: String, ref: String, filename: String): String
}
This is the basic interface, that I want to create adapters for (in the “Ports and Adapters” architecture, types have their dependencies expressed as interfaces, and these are the ports).
Essentially we’re saying that a GitHost
provides a method getFile
and it’s
entirely up to the GitHost
implementation to fulfil the contract.
Creating the adapter to implement the interface reuses the existing code:
class GitHubHost(val github: GitHub) : GitHost {
override fun getFile(repo: String, ref: String, filename: String): String {
return github
.getRepository(repo)
.getFileContent(filename, ref)
.read()
.readAllBytes()
.toString(Charsets.UTF_8)
}
}
fun main(args: Array<String>) {
val client = GitHubHost(GitHub.connectAnonymously())
println(client.getFile("bigkevmcd/github-tool", "master", "README.md"))
}
The GitHubHost
class adapts the GitHub
class, to the GitHost
interface.
Note that in the println
it’s just calling getFile
, and it doesn’t really
have to care about what type of host it’s talking to.
The GitLab case is really simple to implement too, reusing the code from earlier:
class GitLabHost(val gitlab: GitLabApi) : GitHost {
override fun getFile(repo: String, ref: String, filename: String): String {
return gitlab
.repositoryFileApi
.getFile(repo, filename, ref)
.decodedContentAsString
}
}
Now, the GitLabHost
class adapts the GitLabApi
class, to the GitHost
interface.
fun main(args: Array<String>) {
val client = GitLabHost(GitLabApi("https://gitlab.com/", System.getenv("GITLAB_TOKEN"))
println(client.getFile("bigkevmcd/github-tool", "master", "README.md"))
}
The println
line hasn’t changed at all, because the the client is adapting the
underlying GitLab API.
One thing that I have seen before, if you know you’ll want multiple adapters, it’s definitely easier to develop these in parallel, to avoid the conventions of one of the Adaptees leaking into the “port” interface, I’ve seen cases where one implementation is the first one to be completed, and when it comes time to build the second, it’s much harder to fit the API.