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:

Adapter Pattern - Overview

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

Adapter Pattern - 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

Adapter Pattern - Implementations

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.

Adapter Pattern - Behaviour

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.