Elixir: Managing external services with dependency injection
This post is part of the Testing Series, an overview of testing strategies for Elixir applications.
Integrating external services into an application can be straightforward: import a package, deploy it across the project, skip the tests, and quickly move on to the next feature. However, when the need arises to replace these services, developers face the complex task of identifying and refactoring each usage—a process fraught with the potential for introducing errors and causing regressions.
For complex applications that rely on external functionalities, the ability to efficiently switch between service versions or providers is crucial. This flexibility is essential for routine upgrades and for maintaining distinct configurations across development, testing, and production environments. Elixir decouples external services using dependency injection.
Creating an External Service
First, we need an external service. In the text-external-service
directory, there is a simple Python app that takes a URL and, if found, scrapes and returns the text.
Update the ‘docker-compose’
This configures and runs two instances of text-external-service
, one for development at port 8201
and one for production at port 8202
. It is more typical to run a local instance for development and point to a production-grade instance hosted on a remote server.
get_text_dev_service:
build:
context: ./text-external-service
ports:
- "8201:8000"
volumes:
- ./text-external-service:/app
get_text_prod_service:
build:
context: ./text-external-service
ports:
- "8202:8000"
volumes:
- ./text-external-service:/app
Run make up
to generate and start the dev and prod external services.
Test the dev service at http://localhost:8201/docs
Add dependencies
The application needs to make HTTP calls to the external service, which requires a couple of dependencies added to the mix.exs
file.
defp deps do
[
{:hackney, "~> 1.20.1"},
{:httpoison, "~> 2.2.1"}
]
end
- hackney: An HTTP client library for Erlang.
- httpoison: An HTTP client library for Elixir powered by hackney.
Don’t forget to update deps.
Defining the Service Contract with Behaviours
In Erlang/Elixir, behaviours
define a set of functions a module must implement.
defmodule GameApp.External.TextExtractBehaviour do
@callback extract_text(url :: String.t()) ::
{:ok, String.t()} | {:error, String.t()}
end
In this example, the TextExtractBehaviour
specifies a single callback, extract_text
, which takes a URL as a string input and returns the typical tuple of either {:ok, text}
or {:error, reason}
.
Implementing TextExtractService
Next, implement a module with TextExtractBehaviour
that delegates the behaviours.
defmodule GameApp.External.TextExtractService do
@behaviour GameApp.External.TextExtractBehaviour
def extract_text(url) do
impl().extract_text(url)
end
defp impl do
Application.get_env(:game_app, :text_extract_service)
end
end
The GameApp
code recognizes the existence of a service named TextExtractService
, but is unaware of which service is being delegated. This decouples the code from any particular instance of TextExtractService
.
Create TextExtractDevService
This module implements the behaviours
and connects to the external development service via HTTP.
defmodule GameApp.External.TextExtractDevService do
@base_url "http://localhost:8201/"
@behaviour GameApp.External.TextExtractBehaviour
def extract_text(url) do
path = "get-text/"
query = URI.encode_query(%{"url" => url})
url = "#{@base_url}#{path}?#{query}"
headers = [{"accept", "text/plain"}]
case HTTPoison.get(url, headers) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:ok, body}
{:ok, %HTTPoison.Response{status_code: status_code}} ->
{:error, "Failed to fetch text, status code: #{status_code}"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, "HTTP request failed: #{reason}"}
end
end
end
Create TextExtractProdService
In our trivial example, the production service differs only in the base_url
. In a more realistic scenario, it would differ in more meaningful ways, such as including authentication mechanisms and retry scenarios.
defmodule GameApp.External.TextExtractProdService do
@base_url "http://localhost:8202/"
@behaviour GameApp.External.TextExtractBehaviour
def extract_text(url) do
path = "get-text/"
query = URI.encode_query(%{"url" => url})
url = "#{@base_url}#{path}?#{query}"
headers = [{"accept", "text/plain"}]
case HTTPoison.get(url, headers) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
{:ok, body}
{:ok, %HTTPoison.Response{status_code: status_code}} ->
{:error, "Failed to fetch text, status code: #{status_code}"}
{:error, %HTTPoison.Error{reason: reason}} ->
{:error, "HTTP request failed: #{reason}"}
end
end
end
Update the Runtime Configurations
Add a line to config.exs
to direct the application to use TextExtractProdService
in the production environment.
config :game_app, text_extract_service: GameApp.External.TextExtractProdService
Add a line to dev.exs
to direct the application to use TextExtractDevService
in the development environment.
config :game_app, text_extract_service: GameApp.External.TextExtractDevService
Finally, add a line to test.exs
to direct the application to use TextExtractMockService
in the testing environment.
config :game_app, text_extract_service: GameApp.External.TextExtractMockService
This ensures that each environment appropriately uses its corresponding service, maintaining separation of concerns and environment-specific configurations.
Conclusion
In Elixir, dependency injection differs from typical frameworks; it utilizes behaviors and delegation to implement a module that is specified at runtime. This ensures a consistent interface while allowing flexibility and modularity, without the need for a traditional dependency injection container.