Walkthrough of Elixir's Adapter Pattern
Sometimes we need to be able to change how parts of our application behave depending on the environment we’re running in. Examples of this include:
- Toggling sms gateway adapters (production sends a real sms while staging redirects sms to a test phone number)
- Testing with a stubbing library like Mox
If the behavior change is large enough and needs to happen application-wide (like using a different implementation when running in prod vs non-prod), the Adapter pattern makes a lot of sense.
The Adapter pattern
The overall structure of the Adapter pattern looks like this:
Application code calls the API module, which delegates to the current adapter. In order to keep the API module and adapters in sync, we define a behaviour that each module must implement.
The file tree for this pattern looks like this:
lib/
my_app/
sms_gateway.ex - The top-level module that all application code calls
sms_gateway/
adapter.ex - This is the behavior module that defines the callbacks needed for an adapter
default_adapter.ex - An adapter implementation
interceptor_adapter.ex - Adapter implementation
local_adapter.ex - Another adapter implmentation
The beauty of this directory structure is when exploring lib/my_app
we can see that there’s a SMSGateway
that we can call, but it just looks like any other module in our system. If the developer is curious how the SMSGateway
works, they can open the sms_gateway/
directory and discover there are multiple adapters. In most cases these adapters are tucked away so they don’t add noise to the codebase.
The API module
The API module is the top-level module that the rest of the application calls. Application code should be completely unaware of the adapter it’s calling — it’s just calling an interface that fulfills a contract.
defmodule MyApp.SMSGateway do
@moduledoc """
Main API for sending SMS
"""
alias MyApp.SMSGateway.DeliveryError
@type phone_number :: String.t()
@type message :: String.t()
@type message_id :: String.t()
@behaviour MyApp.SMSGateway.Adapter
@doc """
Send a SMS
"""
@impl true
@spec send_sms(phone_number, message) :: {:ok, message_id} | {:error, DeliveryError.t()}
def send_sms(phone_number, message) do
adapter().send_sms(phone_number, message)
end
defp adapter do
Application.get_env(:my_app, :sms_adapter, MyApp.SMSGateway.DefaultAdapter)
end
end
This module is nothing more than a passthrough to the current adapter and should contain very little logic to keep things easy to understand. Its contract should be consistent regardless of the adapter its delegating to. The adapters should wrap error responses in a custom error type to prevent implementation details from leaking through.
Implementation modules
The implementation modules are where the actual logic is written.
defmodule MyApp.SMSGateway.DefaultAdapter do
@moduledoc false
alias MyApp.SMSGateway.DeliveryError
@behaviour MyApp.SMSGateway.Adapter
@impl true
def send_sms(phone_number, message) do
case TwilioClient.send_message(api_key(), phone_number, message) do
{:ok, %{message_id: message_id} ->
{:ok, message_id}
{:error, reason} ->
{:error, DeliveryError.new(reason: reason)}
end
end
Our LocalAdapter may look completely different, which is fine as long as it conforms to the same contract.
defmodule MyApp.SMSGateway.LocalAdapter do
@moduledoc false
alias MyApp.SMSGateway.DeliveryError
@behaviour MyApp.SMSGateway.Adapter
require Logger
@impl true
def send_sms(phone_number, message) do
Logger.info("""
SMS would be sent to #{phone_number}.
#{message}
""")
{:ok, random_id()}
end
defp random_id, do: Base.encode64(:crypto.strong_rand_bytes(20))
end
The key is that the API module says it will return {:ok, message_id}
so we need to ensure we fulfill this contract. Since the LocalAdapter doesn’t have a real message id from an SMS provider, we can just make one up to fulfill the contract.
The Behaviour module
With a top-level API module and multiple implementation modules, it can be difficult to keep the functions, arguments and return types in sync. This is where the Behaviour module comes in.
To write the behaviour, we define the functions each adapter must implement using @callback
.
defmodule MyApp.SMSGateway.Adapter do
@moduledoc false
alias MyApp.SMSGateway
alias MyApp.SMSGateway.DeliveryError
@callback send_sms(SMSGateway.phone_number(), SMSGateway.message()) :: {:ok, SMSGateway.message_id() | {:error, DeliveryError.t()}
end
Again, it’s important to create common success and error return values so each implementation can return common values and not leak implementation details (hence the custom DeliveryError
type).
Then in the API module and each one of the adapter modules, use the behaviour by defining the @behaviour
module attribute and setting @impl true
on each of the behaviour’s functions.
defmodule MyApp.SMSGateway do
@moduledoc """
Main API for sending SMS
"""
@behaviour MyApp.SMSGateway.Adapter
@impl true
def send_sms(phone_number, message) do
#...
end
end
defmodule MyApp.SMSGateway.LocalAdapter do
@moduledoc false
@behaviour MyApp.SMSGateway.Adapter
@impl true
def send_sms(phone_number, message) do
# ...
end
end
Now if a module doesn’t implement the correct functions or arities, the compiler will emit a warning:
warning: function send_sms/2 required by behaviour MyApp.SMSGateway.Adapter is not implemented (in module MyApp.SMSGateway.LocalAdapter)
lib/my_app/sms_gateway/local_adapter.ex:1: MyApp.SMSGateway.LocalAdapter (module)
Benefits of this pattern
While there is some boilerplate code with this pattern, I’d argue that the following benefits outweigh the drawbacks of other patterns.
Adapter switching is isolated to a single place
Sometimes developers will read the adapter from the application enviroment throughout the codebase.
sms_gateway = Application.fetch_env!(:my_app, :sms_gateway)
sms_gateway.send_sms("+15551239876", "Hello world")
One problem with this is now the rest of the codebase has to be aware that there are multiple adapters. Compare this to a simple function call that determines the adapter behind the scenes.
SMSGateway.send_sms("+15551239876", "Hello World")
Allowing our application code to make simple function calls on modules keeps our code cleaner, easier to refactor and even simpler to remove the adapter pattern in the future.
Maintains compatibility with dialyzer
When calling a function on a variable, Dialyzer doesn’t know what module will be returned and therefore can’t do type-checking.
sms_gateway = Application.fetch_env!(:my_app, :sms_gateway)
sms_gateway.send_sms("+15551239876", "Hello world")
When calling a function on a module, Dialyzer can now give us parameter and return type checking like it would anywhere else in the application.
SMSGateway.send_sms("+15551239876", "Hello World")
Furthermore, because we’re using a Behaviour, each of our implementation modules will also be checked to ensure they meet the desired typespecs.
Toggling for the right reasons
Generally, we want to minimize our usage of the adapter pattern because it creates more layers of indirection.
DO:
- Create an adapter if we need to be able to run our application with substantially different functionality depending on the environment. An example of this would be sending real SMS in production vs logging SMS messages locally.
- Create an adapter that allows us to change global configuration with Mox so our tests can be run with
async: true
. More information is available in the testing section below.
DON’T:
- Use the adapter pattern because we’re unsure how to test code that accesses an external service. Instead, use something like bypass that creates a mock version of the external service so our adapter code is fully exercised in test.
Chances are we’ll need to use the adapter pattern significantly less than we’d think, but it’s a good tool to have when we need it.
Testing multiple adapters
One place where I like to use the adapter pattern is the central module where I access application config.
Instead of having calls to Application.get_env/3
throughout my app, I’ll centralize them to a single module like MyApp.Config
:
defmodule MyApp.Config do
@moduledoc """
API for accessing the app's configuration
"""
@spec sms_gateway :: module
def sms_gateway do
Application.get_env(:my_app, :sms_adapter, MyApp.SMSGateway.DefaultAdapter)
end
end
Then I update my API module to call the config module to determine the current adapter.
defmodule MyApp.SMSGateway do
def send_sms(phone_number, message) do
adapter().send_sms(phone_number, message)
end
defp adapter, do: MyApp.Config.sms_gateway()
end
By having these calls centralized to a single module, I can use the adapter pattern for MyApp.Config
to swap in a Mox-based config adapter and allow me to change the adapter used in each test in a way that’s compatible with async: true
. Without it, setting the current adapter using Application.put_env/3
would affect all tests running concurrently in the BEAM because it’s a global operation.
Setting up the adapter pattern for the config module
First, ensure the adapter being tested is controlled by the central config module.
defmodule MyApp.Config do
@moduledoc """
API for accessing the app's configuration
"""
@spec sms_gateway :: module
def sms_gateway do
Application.get_env(:my_app, :sms_adapter, MyApp.SMSGateway.DefaultAdapter)
end
end
Now we can inject the adapter pattern behind MyApp.Config
. First, copy the function we want to mock to MyApp.Config.DefaultAdapter
.
defmodule MyApp.Config.DefaultAdapter do
@moduledoc false
@behaviour MyApp.Config.Adapter
@impl true
def sms_gateway do
Application.get_env(:my_app, :sms_adapter, MyApp.SMSGateway.DefaultAdapter)
end
end
Update MyApp.Config
to delegate to its current adapter. The adapter will default to the DefaultAdapter
if nothing is configured.
defmodule MyApp.Config do
@moduledoc """
API for accessing the app's configuration
"""
@behaviour MyApp.Config.Adapter
@impl true
def sms_gateway do
adapter.sms_gateway()
end
defp adapter do
Application.get_env(:my_app, :config_adapter, MyApp.Config.DefaultAdapter)
end
end
Define the behaviour module to keep the API and implementation in sync. Then update these modules to use the behaviour.
defmodule MyApp.Config.Adapter do
@moduledoc false
@callback sms_gateway :: module
end
defmodule MyApp.SMSGateway.LocalAdapter do
@moduledoc false
@behaviour MyApp.SMSGateway.Adapter
#...
end
Testing multiple adapters with Mox
Now that there’s a behaviour for MyApp.Config
, we can define a mock adapter and use it to stub config values for each test.
First, add Mox to our test dependencies in mix.exs
defp deps do
[{:mox, "~> 1.0", only: :test}]
end
Then in test/test_helper.exs
define the mock and configure it via the application environment:
Mox.defmock(MyApp.ConfigMock, for: MyApp.Config.Adapter)
Application.put_env(:my_app, :config_adapter, MyApp.ConfigMock)
Now in our tests we can use Mox.stub/3
to set the SMS gateway adapter.
defmodule MyApp.SMSGatewayTest do
use ExUnit.Case, async: true
import ExUnit.CaptureLog
alias MyApp.SMSGateway
describe "with local adapter" do
setup do
Mox.stub(MyApp.ConfigMock, :sms_gateway, fn -> MyApp.SMSGateway.LocalAdapter end)
:ok
end
test "successfully sending a message" do
{result, log} =
with_log(fn ->
SMSGateway.send_sms("+15551239876", "Hello world")
end)
assert {:ok, _message_id} = result
assert log =~ "Hello world"
end
end
describe "with default adapter" do
setup do
Mox.stub(MyApp.ConfigMock, :sms_gateway, fn -> MyApp.SMSGateway.DefaultAdapter end)
:ok
end
test "successfully sending a message" #...
end
end
Now anywhere we want to change the SMS gateway adapter (or any other config setting) in a way that’s compatible with async: true
, we can use the following pattern.
Mox.stub(MyApp.ConfigMock, :<function_name>, fn -> "<value_here>" end)
Falling back to MyApp.Config.DefaultAdapter
The more we use the adapter pattern on the centralized config module, we may find ourselves wanting to fall back to the DefaultAdapter’s behavior for most cases unless we have a reason to override it.
defmodule MyApp.Config do
@moduledoc """
API for accessing the app's configuration
"""
@behaviour MyApp.Config.Adapter
@impl true
def sms_gateway, do: adapter.sms_gateway()
@impl true
def twilio_base_url, do: adapter.twilio_base_url()
@impl true
def twilio_api_token, do: adapter.twilio_api_token()
@impl true
def postmark_base_url, do: adapter.postmark_base_url()
@impl true
def postmark_api_key, do: adapter.postmark_api_key()
defp adapter, do: #...
end
In that case, we can use the following line of code to have any function that hasn’t been stubbed on MyApp.ConfigMock
fall back to MyApp.Config.DefaultAdapter
.
Mox.stub_with(MyApp.ConfigMock, MyApp.Config.DefaultAdapter)
This can be added to a setup helper in a custom ExUnit.CaseTemplate
so it can be run automatically, or just called whenever we need it.
With this fallback set, we can now override only the values we want:
setup do
MyApp.ConfigMock
|> Mox.stub(:twilio_base_url, fn -> "http://localhost:2345" end)
|> Mox.stub(:twilio_api_token, fn -> "supersecret" end)
:ok
end
Conclusion
The adapter pattern is a powerful pattern that allows us to swap application behavior when running in different environments. We need to take care not to use it in the wrong scenarios, but when used in the proper situations it can be a critical tool in our toolbox.
The code presented in this article is also available to be reviewed commit-by-commit: https://github.com/aaronrenner/adapter-pattern-example.