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.

Code

Docs

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.

See the code

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.