BoardClic

Tech blog

Maintainable Elixir: Dependency decoupling

Published 14 April 2023

Keeping a project maintainable over time requires authors to think ahead, plan and, most importantly, understand what potential problems may arise in the future. There are plenty of sound principles and development philosophies one can learn and follow to keep things in check. Principles/philosophies like these are especially beneficial when building maintainable software that stays maintainable.

While it’s not entirely obvious to everyone, isolating external dependencies by wrapping them in a self-owned abstraction is an uncomplicated way to make your code more effortless to maintain.

Let’s consider this example:

defmodule MyAppWeb.ArticleComponents do
  use MyAppWeb, :component

  alias Timex.Format.DateTime.Formatters.Strftime

  def article_header(assigns) do
    ~H"""
    <h1><%= @article.title %></h1>

    <p>Created by <%= @article.author %>, <%= Timex.lformat!(@article.published_at,
    "%-0d %B %Y %H:%m", "en", Strftime) %></p>

    <%= if edited?(@article) do %>
      <p><%= Timex.lformat!(@article.edited_at, "%d %b %Y %H:%m", "en", Strftime) %></p>
    <% end %>
    """
  end
end

At first glance, it looks alright; of course, we could use more semantic tags for our timestamps, but we’ll focus on something else here.

Instead, I want us to examine how we use the external dependency Timex. In the example, we had to define a long and awkward alias to reference the particular formatter that enables strftime syntax. Also, we have to define our formatting strings manually, which means there is a high risk that we create inconsistently formatted dates. Reading and understanding the strftime will also take longer than it should.

Isolation through abstraction

A way to remedy these problems, which I recommended for most libraries that you don’t want to consider core libraries to your application, is to wrap them in a local abstraction.

You’d reap the benefits of being able to define your application’s internal API to make the features you’d like to use fit your use cases and preferences while at the same time being able to replace the dependency for something else in a heartbeat in case your needs change.

Let’s see how the abstraction might look:

defmodule MyAppWeb.TimeFormatter do
  alias Timex.Format.DateTime.Formatters.Strftime

  @doc """
  Format a `Date`/`DateTime`/`NaiveDateTime` through a semantic format.
  """
  def format!(datetime, format, locale) do
    Timex.lformat!(datetime, strftime_format(format), locale, Strftime)
  end

  @formats %{
    long: "%-0d %B %Y %H:%m",
    short: "%-0d %b"
  }

  @format_keys Enum.keys(@formats)

  defp strftime_format(key) when key in @format_keys do
    @formats[key]
  end

  defp strftime_format(invalid_format) do
    raise ArgumentError, """
    The provided format `#{inspect(invalid_format)}` is not a valid format.

    defined formats:
    #{unquote(Enum.map_join(@format_keys, "\n", & "`#{&1}`"))}
    """
  end
end

And then we’d end up with this in our component instead:

defmodule MyAppWeb.ArticleComponents do
  use MyAppWeb, :component

  alias MyAppWeb.TimeFormatter

  def article_header(assigns) do
    ~H"""
    <h1><%= @article.title %></h1>

    <p>Created by <%= @article.author %>, <%= TimeFormatter.format!(@article.published_at, :long, "en") %></p>

    <%= if edited?(@article) do %>
      <p><%= TimeFormatter.format!(@article.edited_at, :long, "en") %></p>
    <% end %>
    """
  end
end

Neat, right?

Continue reflecting

If this is new to you, look around in a newly generated Phoenix project with your newly trained eyes. You’d start seeing how Phoenix creates similar abstractions for you through the generators in your project (Mailer, Gettext, Repo and more). It should be more evident that they nudge you into working this way, even with Phoenix’s internals.

Johan Tell
Johan Tell