Mix: Using Xref to Enforce better Design

Elixir is one of my favorite languages! Dealing in functions, pipes, Process-Oriented programming, OTP and a great community make working in Elixir a great experience. However, there isn’t a better workflow/means to limit access control across modules (for example: protected in ruby). Because of this deficiency, I often find myself working in a project where apps try to make calls to modules that they shouldn’t.

One classic example that comes to mind is a phoenix app in an umbrella. Often times people make calls to the Repo in the ecto app from Phoenix controllers. In phoenix 1.3, Contexts were introduced which add an implicit layer of abstraction around a set of database operations. The idea was to use these contexts as an API to make changes to the database. This was a great step forward and a clean solution to the above problem, besides the fact that you could still make calls to the Repo. So, Using contexts as means of communication with the database wasn’t enforced and I didn’t like that.

Recently, I came across mix xref task which can be used to do cross reference checks between the modules of a mix project. So, I did the obvious, use it to enforce better design for my applications.

Let’s take an umbrella of apps, my_app_umbrella with apps: my_app and my_app_web.

Here my_app is the ecto app which has MyApp.Repo defined and my_app_web is the phoenix app.

We can create a new mix task which checks for cross reference and raises an error if apps other than my_app/ try to reference MyApp.Repo.

# in mix.exs of the umbrella project.
defmodule MyApp.Umbrella.Mixfile do
  use Mix.Project

  @allowed_xrefs [{MyApp.Repo, [~r{apps/my_app/}]}]

  def project do
    [
      apps_path: "apps",
      start_permanent: Mix.env == :prod,
      deps: deps(),
      aliases: aliases()
    ]
  end

  defp deps do
    []
  end

  defp aliases do
    [
      "test.xref": &test_xref/1,
    ]
  end

  defp test_xref(_) do
    Enum.each(@allowed_xrefs, fn {callee, callers} ->
      case System.cmd("sh", ["-c", "mix xref callers #{callee}"]) do
        {result, 0} ->
          check_allowed(callers, callee, result)
        _ -> raise "Unexpected Error"
      end
    end)
  end

  defp check_allowed(callers, callee, string) do
    string
    |> String.split("==>")
    |> Enum.map(&String.trim/1)
    |> Enum.filter(& &1 != "")
    |> Enum.each(&check_allowed_for_app(&1, callee, callers))

    require Logger
    Logger.info("All ok")
  end

  def check_allowed_for_app(app_string, callee, callers) do
    [app | files] = String.split(app_string, "\n")

    case (files
      |> Enum.map(& "apps/#{app}/" <> &1)
      |> Enum.filter(& !match_callers?(callers, &1))) do
      [] -> :ok
      files -> raise "Tried to access #{callee} from #{Enum.join(files, ", ")}.. Not allowed!"
    end
  end

  def match_callers?(callers, file) do
    Enum.any?(callers, &String.match?(file, &1))
  end
end

Here the module attribute, @allowed_xrefs, is a list of tuples. First element of the tuple is a callee (the module whose access we want to control) and the second element of the tuple is a list of callers (list of regexps of files which are allowed to access the callee). In this example, callee is MyApp.Repo and callers include ~r{apps/my_app/}. This means only files in the app my_app can make calls to the callee, MyApp.Repo.

This allows us to run $ mix test.xref to check if there are any files calling modules that we have limited access to.

Test Xref

DDD Heaven


This task can further be extended to functions of a callee, to limit calls to a particular function in a module.

Check out mix xref for more information on the task and to extend the usage of $ mix test.xref.