Flexible Design with Adapters
March 17, 2018
elixir
patterns
With Phoenix 1.3 well behind us now, Elixir developers understand how to approach most problems “The Elixir Way”:
- Create a well-named context module for the problem, e.g. “Payment”
- Put functions in it, which don’t expose how data is persisted
- Profit
This is great, but it can be taken even further. A Payment
module is a great example, in fact. Many developers1 would scan the
landscape for the best payment gateway out there2, find its companion
library in Hex, and build their whole Payment
module around its API. They
would use the gateway library’s structs throughout their application, and
name fields in the database using gateway-specific terms.
This gets the job done, but it can be shortsighted. Eventually, the stakeholders always want to do crazy things like:
- Switch payment gateways (“Authorize.net gave us a better deal!”)
- Route some payments through gateway A and others through gateway B
This kind of change is very common whenever the third party service you’re integrating is generic and commoditized, i.e. offered by more than one vendor. For example:
- File uploading and storage
- Image manipulation
- Payment processing
- SMS delivery
- Email delivery
- Content-delivery networks
- Analytics
- Performance & error monitoring
If your app isn’t designed to anticipate this kind of change, you’ll be caught flatfooted whenever you need to change vendors for any of these services.
Introducing Adapters
In Elixir, the best way to insulate your app from vendor changes is to
isolate your vendor-specific code into Adapter
modules.
Before you get to that though, you’d first define app-specific structs for
your problem space. For example, you might create a MyApp.Payment.Charge
struct to represent credit card charges.
You’d use these structs exclusively throughout your app, never depending on vendor-specific structs or types.
%MyApp.Payment.Charge{
gateway_id: "[id of charge on gateway]",
amount_cents: 1_000,
currency: "usd",
# ...
}
Next, you’d identify which features you need from your vendor, and codify
those into an Adapter
behaviour. In this case, Gateway
seems to be a good
name.
defmodule MyApp.Payment.Gateway do
alias MyApp.Payment.{Charge, GatewayError}
@callback create_charge(Charge.t) :: {:ok, Charge.t} | {:error, GatewayError.t}
end
You’d then implement that behaviour for your chosen vendor.
defmodule MyApp.Payment.StripeGateway do
@behaviour MyApp.Payment.Gateway
@impl true
def create_charge(charge) do
# ...
end
end
Inside your context, when you need to make a call to your vendor, you fetch
an Adapter
module from configuration and call it:
defmodule MyApp.Payment do
def create_charge(attrs \\ %{}) do
%MyApp.Payment.Charge{}
|> struct(attrs)
|> gateway().create_charge()
end
defp gateway do
:my_app
|> Application.get_env(__MODULE__, [])
|> Keyword.fetch!(:gateway)
end
end
Finally, you’d configure your context to use the desired Adapter
.
config :my_app, MyApp.Payment,
gateway: MyApp.Payment.StripeGateway
Congratulations, you now have an adapter-based design!
Benefits
Now that your vendor is isolated behind a behaviour, you can much more easily support multiple vendors. Beyond that, you also get a major benefit in your tests.
You can now easily implement a Test
implementation of your Adapter
which
you only use in the :test
environment. This allows you to thoroughly test
all the possible error states, without having to make live API calls. This is
particularly nice when your vendor doesn’t provide a good “test” version of
their API.
Examples
In conclusion, here are some stand-out libraries on Hex which implement the pattern if you want to see it in action: