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.